diff --git a/internal/db/device_types.go b/internal/db/device_types.go new file mode 100644 index 0000000..07c8e08 --- /dev/null +++ b/internal/db/device_types.go @@ -0,0 +1,351 @@ +package db + +import ( + "database/sql" + "errors" + "fmt" + "strings" +) + +// ErrForbidden is the sentinel for "you can't mutate this row" — used by +// PATCH/DELETE on built-in device_types. +var ErrForbidden = errors.New("forbidden") + +// ----------------------------------------------------------------------------- +// device_types +// ----------------------------------------------------------------------------- + +// DeviceTypeCreate is the shape POSTed under /api/projects/:pid/device-types. +// project_id is the URL :pid; the caller never passes it in the body. +type DeviceTypeCreate struct { + Name string + Kind string + Icon string + Description string + Ports []DeviceTypePortCreate +} + +// DeviceTypePortCreate is one row in the type's port profile. +type DeviceTypePortCreate struct { + CableTypeID int64 + LabelPrefix string + Count int + Edge string + SortOrder int +} + +// DeviceTypeUpdate is the partial-update shape. Built-in types reject +// any PATCH at the store level. +type DeviceTypeUpdate struct { + Name *string + Kind *string + Icon *string + Description *string + // Ports != nil means "replace the port profile with this set". + Ports *[]DeviceTypePortCreate +} + +// CreateDeviceType inserts a project-custom row + its port profile in +// one transaction. projectID must be non-zero (built-ins are seed-only). +func (s *Store) CreateDeviceType(projectID int64, dt DeviceTypeCreate) (*DeviceType, error) { + name := strings.TrimSpace(dt.Name) + if name == "" { + return nil, fmt.Errorf("%w: name is required", ErrInvalidInput) + } + if projectID == 0 { + return nil, fmt.Errorf("%w: project_id is required (built-ins are seed-only)", ErrInvalidInput) + } + if _, err := s.GetProject(projectID); err != nil { + return nil, err + } + // Forbid name-collisions with built-ins (UNIQUE(project_id,name) + // only enforces inside the project; built-ins have project_id IS + // NULL so the constraint doesn't catch them). + var builtinClash int + if err := s.db.QueryRow( + `SELECT COUNT(*) FROM device_types WHERE project_id IS NULL AND name = ?`, name, + ).Scan(&builtinClash); err != nil { + return nil, err + } + if builtinClash > 0 { + return nil, fmt.Errorf("%w: name %q clashes with a built-in device type", ErrConflict, name) + } + + kind := strings.TrimSpace(dt.Kind) + if kind == "" { + kind = "generic" + } + desc := dt.Description + var iconPtr any + if icon := strings.TrimSpace(dt.Icon); icon != "" { + iconPtr = icon + } + + tx, err := s.db.Begin() + if err != nil { + return nil, err + } + defer tx.Rollback() + + res, err := tx.Exec( + `INSERT INTO device_types (project_id, name, kind, icon, description, built_in) + VALUES (?, ?, ?, ?, ?, 0)`, + projectID, name, kind, iconPtr, desc, + ) + if err != nil { + return nil, mapWriteErr(err) + } + id, _ := res.LastInsertId() + + for _, p := range dt.Ports { + if err := insertDeviceTypePort(tx, id, p); err != nil { + return nil, err + } + } + if err := tx.Commit(); err != nil { + return nil, err + } + return s.GetDeviceType(id) +} + +func insertDeviceTypePort(tx *sql.Tx, deviceTypeID int64, p DeviceTypePortCreate) error { + if p.CableTypeID <= 0 { + return fmt.Errorf("%w: cable_type_id is required on each port row", ErrInvalidInput) + } + if p.Count <= 0 { + p.Count = 1 + } + edge := strings.TrimSpace(p.Edge) + if edge == "" { + edge = "bottom" + } + if edge != "top" && edge != "bottom" && edge != "left" && edge != "right" { + return fmt.Errorf("%w: edge must be top/bottom/left/right", ErrInvalidInput) + } + _, err := tx.Exec( + `INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order) + VALUES (?, ?, ?, ?, ?, ?)`, + deviceTypeID, p.CableTypeID, p.LabelPrefix, p.Count, edge, p.SortOrder, + ) + if err != nil { + return mapWriteErr(err) + } + return nil +} + +// GetDeviceType loads a single type row (built-in OR project-custom) +// with its port profile. +func (s *Store) GetDeviceType(id int64) (*DeviceType, error) { + dt, err := scanDeviceTypeByID(s.db, id) + if err != nil { + return nil, err + } + ports, err := s.listDeviceTypePorts(id) + if err != nil { + return nil, err + } + dt.Ports = ports + return dt, nil +} + +func scanDeviceTypeByID(d *sql.DB, id int64) (*DeviceType, error) { + var dt DeviceType + var proj sql.NullInt64 + var icon sql.NullString + var built int + err := d.QueryRow( + `SELECT id, project_id, name, kind, icon, description, built_in, created_at, updated_at + FROM device_types WHERE id = ?`, id, + ).Scan(&dt.ID, &proj, &dt.Name, &dt.Kind, &icon, &dt.Description, &built, + &dt.CreatedAt, &dt.UpdatedAt) + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrNotFound + } + if err != nil { + return nil, err + } + if proj.Valid { + v := proj.Int64 + dt.ProjectID = &v + } + if icon.Valid { + dt.Icon = &icon.String + } + dt.BuiltIn = built != 0 + return &dt, nil +} + +// ListBuiltInDeviceTypes returns every built-in type (project_id IS NULL). +func (s *Store) ListBuiltInDeviceTypes() ([]DeviceType, error) { + return s.listDeviceTypesWhere(`project_id IS NULL`, nil) +} + +// ListDeviceTypesForProject returns built-ins + the project's custom +// types, merged. Built-ins come first (insertion order), then custom by +// id. +func (s *Store) ListDeviceTypesForProject(projectID int64) ([]DeviceType, error) { + return s.listDeviceTypesWhere( + `project_id IS NULL OR project_id = ?`, []any{projectID}, + ) +} + +func (s *Store) listDeviceTypesWhere(where string, args []any) ([]DeviceType, error) { + q := `SELECT id, project_id, name, kind, icon, description, built_in, created_at, updated_at + FROM device_types WHERE ` + where + + ` ORDER BY (project_id IS NOT NULL), id` + rows, err := s.db.Query(q, args...) + if err != nil { + return nil, err + } + defer rows.Close() + out := []DeviceType{} + for rows.Next() { + var dt DeviceType + var proj sql.NullInt64 + var icon sql.NullString + var built int + if err := rows.Scan(&dt.ID, &proj, &dt.Name, &dt.Kind, &icon, &dt.Description, &built, + &dt.CreatedAt, &dt.UpdatedAt); err != nil { + return nil, err + } + if proj.Valid { + v := proj.Int64 + dt.ProjectID = &v + } + if icon.Valid { + dt.Icon = &icon.String + } + dt.BuiltIn = built != 0 + out = append(out, dt) + } + if err := rows.Err(); err != nil { + return nil, err + } + // Hydrate ports per row. Two queries per request is fine for the + // catalog size; switch to a single JOIN-and-group if it becomes hot. + for i := range out { + ps, err := s.listDeviceTypePorts(out[i].ID) + if err != nil { + return nil, err + } + out[i].Ports = ps + } + return out, nil +} + +func (s *Store) listDeviceTypePorts(deviceTypeID int64) ([]DeviceTypePort, error) { + rows, err := s.db.Query( + `SELECT id, device_type_id, cable_type_id, label_prefix, count, edge, sort_order + FROM device_type_ports WHERE device_type_id = ? ORDER BY sort_order, id`, + deviceTypeID, + ) + if err != nil { + return nil, err + } + defer rows.Close() + out := []DeviceTypePort{} + for rows.Next() { + var p DeviceTypePort + if err := rows.Scan(&p.ID, &p.DeviceTypeID, &p.CableTypeID, + &p.LabelPrefix, &p.Count, &p.Edge, &p.SortOrder); err != nil { + return nil, err + } + out = append(out, p) + } + return out, rows.Err() +} + +// UpdateDeviceType applies a partial update. Built-in rows are rejected +// with ErrForbidden. Cross-project rows are rejected with ErrNotFound. +// Replacing the port profile (Ports != nil) wipes and re-inserts. +func (s *Store) UpdateDeviceType(projectID, id int64, u DeviceTypeUpdate) (*DeviceType, error) { + cur, err := s.GetDeviceType(id) + if err != nil { + return nil, err + } + if cur.BuiltIn { + return nil, fmt.Errorf("%w: built-in device types are read-only", ErrForbidden) + } + if cur.ProjectID == nil || *cur.ProjectID != projectID { + return nil, ErrNotFound + } + + if u.Name != nil { + v := strings.TrimSpace(*u.Name) + if v == "" { + return nil, fmt.Errorf("%w: name cannot be empty", ErrInvalidInput) + } + cur.Name = v + } + if u.Kind != nil { + v := strings.TrimSpace(*u.Kind) + if v == "" { + v = "generic" + } + cur.Kind = v + } + if u.Icon != nil { + v := strings.TrimSpace(*u.Icon) + if v == "" { + cur.Icon = nil + } else { + cur.Icon = &v + } + } + if u.Description != nil { + cur.Description = *u.Description + } + + tx, err := s.db.Begin() + if err != nil { + return nil, err + } + defer tx.Rollback() + + var iconArg any + if cur.Icon != nil { + iconArg = *cur.Icon + } + if _, err := tx.Exec( + `UPDATE device_types + SET name = ?, kind = ?, icon = ?, description = ?, updated_at = datetime('now') + WHERE id = ?`, + cur.Name, cur.Kind, iconArg, cur.Description, id, + ); err != nil { + return nil, mapWriteErr(err) + } + if u.Ports != nil { + if _, err := tx.Exec(`DELETE FROM device_type_ports WHERE device_type_id = ?`, id); err != nil { + return nil, err + } + for _, p := range *u.Ports { + if err := insertDeviceTypePort(tx, id, p); err != nil { + return nil, err + } + } + } + if err := tx.Commit(); err != nil { + return nil, err + } + return s.GetDeviceType(id) +} + +// DeleteDeviceType removes a project-custom row. Built-ins → ErrForbidden. +// Cross-project → ErrNotFound. Cascades to device_type_ports (FK CASCADE) +// and SET-NULLs the type_id on any device referencing it. +func (s *Store) DeleteDeviceType(projectID, id int64) error { + cur, err := s.GetDeviceType(id) + if err != nil { + return err + } + if cur.BuiltIn { + return fmt.Errorf("%w: built-in device types are read-only", ErrForbidden) + } + if cur.ProjectID == nil || *cur.ProjectID != projectID { + return ErrNotFound + } + if _, err := s.db.Exec(`DELETE FROM device_types WHERE id = ?`, id); err != nil { + return err + } + return nil +} diff --git a/internal/db/device_types_test.go b/internal/db/device_types_test.go new file mode 100644 index 0000000..869596f --- /dev/null +++ b/internal/db/device_types_test.go @@ -0,0 +1,283 @@ +package db + +import ( + "errors" + "testing" +) + +// -------------------------------------------------------- catalog (seeded) + +func TestSeed_BuiltInDeviceTypes(t *testing.T) { + s := newTestStore(t) + got, err := s.ListBuiltInDeviceTypes() + if err != nil { + t.Fatalf("list: %v", err) + } + want := []string{ + "NAS", "PC", "Mac", "Notebook", "TV", "Soundbar", "Switch", "fritz", + "ChromeCast", "SteamLink", "IOx-3", "IOx-6", "IOx-8", + "Screen", "Keyboard", "Mouse", + } + if len(got) != len(want) { + t.Fatalf("built-in count = %d, want %d", len(got), len(want)) + } + for i, w := range want { + if got[i].Name != w { + t.Errorf("[%d] = %q, want %q", i, got[i].Name, w) + } + if !got[i].BuiltIn { + t.Errorf("[%d] %q should be built_in", i, got[i].Name) + } + if got[i].ProjectID != nil { + t.Errorf("[%d] %q should have project_id=nil", i, got[i].Name) + } + } +} + +func TestSeed_PortProfiles(t *testing.T) { + s := newTestStore(t) + all, _ := s.ListBuiltInDeviceTypes() + byName := map[string]DeviceType{} + for _, d := range all { + byName[d.Name] = d + } + cases := map[string]struct { + totalPorts int // sum of count across profile rows + }{ + "NAS": {2}, // Power 1 + RJ45 1 + "PC": {5}, // Power 1 + RJ45 1 + HDMI 1 + USB 2 + "Mac": {4}, // Power 1 + HDMI 1 + USB 2 + "Notebook": {3}, // Power 1 + USB 2 + "TV": {3}, // Power 1 + HDMI 2 + "Soundbar": {2}, // Power 1 + HDMI 1 + "Switch": {6}, // Power 1 + RJ45 5 + "fritz": {5}, // Power 1 + RJ45 4 + "ChromeCast": {2}, // Power 1 + HDMI 1 + "SteamLink": {4}, // Power 1 + HDMI 1 + USB 2 + "IOx-3": {4}, // Power 1 + USB 3 + "IOx-6": {7}, // Power 1 + USB 6 + "IOx-8": {9}, // Power 1 + USB 8 + "Screen": {2}, // Power 1 + HDMI 1 + "Keyboard": {1}, // USB 1 + "Mouse": {1}, // USB 1 + } + for name, want := range cases { + dt, ok := byName[name] + if !ok { + t.Errorf("missing built-in %q", name) + continue + } + total := 0 + for _, p := range dt.Ports { + total += p.Count + } + if total != want.totalPorts { + t.Errorf("%s: total ports = %d, want %d", name, total, want.totalPorts) + } + } +} + +// -------------------------------------------------------- CRUD (custom rows) + +func TestCreateDeviceType_CustomBasic(t *testing.T) { + s := newTestStore(t) + p, _ := s.CreateProject("LOFT", "", "") + dt, err := s.CreateDeviceType(p.ID, DeviceTypeCreate{ + Name: "DigitalCam", Kind: "accessory", + Description: "A camera with HDMI out", + Ports: []DeviceTypePortCreate{ + {CableTypeID: 1, LabelPrefix: "Power", Count: 1}, + {CableTypeID: 3, LabelPrefix: "HDMI", Count: 1, SortOrder: 1}, + }, + }) + if err != nil { + t.Fatalf("create: %v", err) + } + if dt.BuiltIn { + t.Errorf("built_in should be false") + } + if dt.ProjectID == nil || *dt.ProjectID != p.ID { + t.Errorf("project_id mismatch: %+v", dt.ProjectID) + } + if len(dt.Ports) != 2 { + t.Errorf("port profile rows = %d, want 2", len(dt.Ports)) + } +} + +func TestCreateDeviceType_NameClashWithBuiltIn(t *testing.T) { + s := newTestStore(t) + p, _ := s.CreateProject("LOFT", "", "") + _, err := s.CreateDeviceType(p.ID, DeviceTypeCreate{Name: "NAS"}) + if !errors.Is(err, ErrConflict) { + t.Errorf("err = %v, want ErrConflict (NAS is built-in)", err) + } +} + +func TestCreateDeviceType_PerProjectUnique(t *testing.T) { + s := newTestStore(t) + p, _ := s.CreateProject("LOFT", "", "") + if _, err := s.CreateDeviceType(p.ID, DeviceTypeCreate{Name: "Foo"}); err != nil { + t.Fatalf("first: %v", err) + } + if _, err := s.CreateDeviceType(p.ID, DeviceTypeCreate{Name: "Foo"}); !errors.Is(err, ErrConflict) { + t.Errorf("dup err = %v, want ErrConflict", err) + } +} + +func TestUpdateDeviceType_BuiltInForbidden(t *testing.T) { + s := newTestStore(t) + p, _ := s.CreateProject("LOFT", "", "") + all, _ := s.ListBuiltInDeviceTypes() + nas := all[0] + newName := "renamed" + _, err := s.UpdateDeviceType(p.ID, nas.ID, DeviceTypeUpdate{Name: &newName}) + if !errors.Is(err, ErrForbidden) { + t.Errorf("err = %v, want ErrForbidden", err) + } +} + +func TestDeleteDeviceType_BuiltInForbidden(t *testing.T) { + s := newTestStore(t) + p, _ := s.CreateProject("LOFT", "", "") + all, _ := s.ListBuiltInDeviceTypes() + if err := s.DeleteDeviceType(p.ID, all[0].ID); !errors.Is(err, ErrForbidden) { + t.Errorf("err = %v, want ErrForbidden", err) + } +} + +func TestUpdateDeviceType_CrossProjectIsNotFound(t *testing.T) { + s := newTestStore(t) + p1, _ := s.CreateProject("LOFT", "", "") + p2, _ := s.CreateProject("OFFICE", "", "") + dt, _ := s.CreateDeviceType(p1.ID, DeviceTypeCreate{Name: "Foo"}) + newName := "bar" + if _, err := s.UpdateDeviceType(p2.ID, dt.ID, DeviceTypeUpdate{Name: &newName}); !errors.Is(err, ErrNotFound) { + t.Errorf("err = %v, want ErrNotFound", err) + } +} + +// -------------------------------------------------------- device + ports seed + +func TestCreateDevice_SeedsPortsFromBuiltInType(t *testing.T) { + s := newTestStore(t) + p, _ := s.CreateProject("LOFT", "", "") + all, _ := s.ListBuiltInDeviceTypes() + var nasID int64 + for _, dt := range all { + if dt.Name == "NAS" { + nasID = dt.ID + break + } + } + if nasID == 0 { + t.Fatal("NAS not in catalog") + } + d, err := s.CreateDevice(p.ID, DeviceCreate{ + Name: "NAS-Loft", TypeID: &nasID, + X: 100, Y: 100, Width: 100, Height: 35, + }) + if err != nil { + t.Fatalf("create: %v", err) + } + if d.TypeID == nil || *d.TypeID != nasID { + t.Errorf("type_id wrong: %+v", d.TypeID) + } + ports, _ := s.ListPortsForProject(p.ID) + if len(ports) != 2 { + t.Fatalf("port count = %d, want 2 (Power + RJ45)", len(ports)) + } + for _, prt := range ports { + if prt.YOffset != 35 { + t.Errorf("port y_offset = %v, want 35 (bottom edge)", prt.YOffset) + } + if prt.XOffset <= 0 || prt.XOffset >= 100 { + t.Errorf("port x_offset = %v, want between 0 and 100", prt.XOffset) + } + if prt.Label == nil { + t.Errorf("port label = nil, want non-nil (label_prefix is set)") + } + } +} + +func TestCreateDevice_SeedsPortsForPC_FourGroupsFiveTotal(t *testing.T) { + s := newTestStore(t) + p, _ := s.CreateProject("LOFT", "", "") + all, _ := s.ListBuiltInDeviceTypes() + var pcID int64 + for _, dt := range all { + if dt.Name == "PC" { + pcID = dt.ID + break + } + } + if pcID == 0 { + t.Fatal("PC not in catalog") + } + if _, err := s.CreateDevice(p.ID, DeviceCreate{ + Name: "Workstation", TypeID: &pcID, + X: 0, Y: 0, Width: 100, Height: 35, + }); err != nil { + t.Fatalf("create: %v", err) + } + ports, _ := s.ListPortsForProject(p.ID) + if len(ports) != 5 { + t.Errorf("port count = %d, want 5 (Power+RJ45+HDMI+USB×2)", len(ports)) + } + // USB×2 must produce two labels "USB 1" and "USB 2". + usbLabels := map[string]bool{} + for _, prt := range ports { + if prt.Label != nil && (*prt.Label == "USB 1" || *prt.Label == "USB 2") { + usbLabels[*prt.Label] = true + } + } + if !usbLabels["USB 1"] || !usbLabels["USB 2"] { + t.Errorf("USB labels missing: got %v", usbLabels) + } +} + +func TestCreateDevice_NoTypeID_NoPorts(t *testing.T) { + s := newTestStore(t) + p, _ := s.CreateProject("LOFT", "", "") + if _, err := s.CreateDevice(p.ID, DeviceCreate{ + Name: "Freeform", X: 0, Y: 0, Width: 100, Height: 35, + }); err != nil { + t.Fatalf("create: %v", err) + } + ports, _ := s.ListPortsForProject(p.ID) + if len(ports) != 0 { + t.Errorf("freeform device should have 0 ports, got %d", len(ports)) + } +} + +func TestCreateDevice_CrossProjectCustomTypeRejected(t *testing.T) { + s := newTestStore(t) + p1, _ := s.CreateProject("LOFT", "", "") + p2, _ := s.CreateProject("OFFICE", "", "") + custom, _ := s.CreateDeviceType(p1.ID, DeviceTypeCreate{Name: "Exotic"}) + _, err := s.CreateDevice(p2.ID, DeviceCreate{ + Name: "Wrong", TypeID: &custom.ID, + X: 0, Y: 0, Width: 100, Height: 35, + }) + if !errors.Is(err, ErrInvalidInput) { + t.Errorf("err = %v, want ErrInvalidInput (cross-project custom type)", err) + } +} + +func TestSnapshot_IncludesPorts(t *testing.T) { + s := newTestStore(t) + p, _ := s.CreateProject("LOFT", "", "") + all, _ := s.ListBuiltInDeviceTypes() + for _, dt := range all { + if dt.Name == "Mac" { + _, _ = s.CreateDevice(p.ID, DeviceCreate{ + Name: "M1", TypeID: &dt.ID, + X: 0, Y: 0, Width: 100, Height: 35, + }) + break + } + } + snap, _ := s.Snapshot(p.ID) + if len(snap.Ports) != 4 { + t.Errorf("snapshot.Ports = %d, want 4 (Mac: Power+HDMI+USB×2)", len(snap.Ports)) + } +} diff --git a/internal/db/frames_devices.go b/internal/db/frames_devices.go index 3f7047a..7d241d3 100644 --- a/internal/db/frames_devices.go +++ b/internal/db/frames_devices.go @@ -166,9 +166,14 @@ func (s *Store) DeleteFrame(projectID, id int64) error { // ----------------------------------------------------------------------------- // DeviceCreate is the create-shape. FrameID may be nil ("outside any frame"). +// TypeID may be nil for a freeform device (no auto-seeded ports). If set, +// the type must be either built-in or a project-custom type belonging to +// the same project — and CreateDevice seeds the device's ports from the +// type's port profile in the same transaction. type DeviceCreate struct { Name string FrameID *int64 + TypeID *int64 Color string X float64 Y float64 @@ -179,10 +184,11 @@ type DeviceCreate struct { // DeviceUpdate is the partial-update shape. project_id deliberately not // settable. FrameID is *(*int64) so callers can distinguish "leave as-is" // (nil) from "set to NULL" (&nil) — Go syntax: pass a *(*int64) where the -// inner pointer is nil to clear. +// inner pointer is nil to clear. TypeID uses the same FrameRef tri-state. type DeviceUpdate struct { Name *string FrameID FrameRef // see FrameRef below + TypeID FrameRef // tri-state for type_id: same shape as FrameRef Color *string X *float64 Y *float64 @@ -201,7 +207,11 @@ type FrameRef struct { } // CreateDevice inserts a new device. FrameID, if provided, must reference -// a frame in the same project. +// a frame in the same project. TypeID, if provided, must reference a +// built-in or a project-custom device_type in the same project — the +// store seeds the device's ports from that type's profile in the same +// transaction so a half-created device (row inserted, ports missing) +// can never exist. func (s *Store) CreateDevice(projectID int64, d DeviceCreate) (*Device, error) { name := strings.TrimSpace(d.Name) if name == "" { @@ -221,32 +231,62 @@ func (s *Store) CreateDevice(projectID int64, d DeviceCreate) (*Device, error) { return nil, err } } + if d.TypeID != nil { + dt, err := s.GetDeviceType(*d.TypeID) + if err != nil { + if errors.Is(err, ErrNotFound) { + return nil, fmt.Errorf("%w: device type %d not found", ErrInvalidInput, *d.TypeID) + } + return nil, err + } + // Project-custom types must match the device's project. Built-ins + // (project_id NULL) are available to every project. + if dt.ProjectID != nil && *dt.ProjectID != projectID { + return nil, fmt.Errorf("%w: device type %d is custom to another project", ErrInvalidInput, *d.TypeID) + } + } color := strings.TrimSpace(d.Color) if color == "" { color = "#1e1e1e" } - res, err := s.db.Exec( - `INSERT INTO devices (project_id, frame_id, name, color, x, y, width, height) - VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, - projectID, nullableInt64(d.FrameID), name, color, d.X, d.Y, d.Width, d.Height, + tx, err := s.db.Begin() + if err != nil { + return nil, err + } + defer tx.Rollback() + + res, err := tx.Exec( + `INSERT INTO devices (project_id, frame_id, type_id, name, color, x, y, width, height) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + projectID, nullableInt64(d.FrameID), nullableInt64(d.TypeID), + name, color, d.X, d.Y, d.Width, d.Height, ) if err != nil { return nil, mapWriteErr(err) } - id, _ := res.LastInsertId() - return s.GetDevice(projectID, id) + deviceID, _ := res.LastInsertId() + + if d.TypeID != nil { + if err := s.seedPortsFromType(tx, projectID, deviceID, *d.TypeID, d.Width, d.Height); err != nil { + return nil, err + } + } + if err := tx.Commit(); err != nil { + return nil, err + } + return s.GetDevice(projectID, deviceID) } // GetDevice loads a device, project-scoped. func (s *Store) GetDevice(projectID, id int64) (*Device, error) { var d Device - var frame sql.NullInt64 + var frame, typeID sql.NullInt64 var ex sql.NullString err := s.db.QueryRow( - `SELECT id, project_id, frame_id, name, color, x, y, width, height, excalidraw_id, created_at, updated_at + `SELECT id, project_id, frame_id, type_id, name, color, x, y, width, height, excalidraw_id, created_at, updated_at FROM devices WHERE id = ? AND project_id = ?`, id, projectID, - ).Scan(&d.ID, &d.ProjectID, &frame, &d.Name, &d.Color, &d.X, &d.Y, &d.Width, &d.Height, + ).Scan(&d.ID, &d.ProjectID, &frame, &typeID, &d.Name, &d.Color, &d.X, &d.Y, &d.Width, &d.Height, &ex, &d.CreatedAt, &d.UpdatedAt) if errors.Is(err, sql.ErrNoRows) { return nil, ErrNotFound @@ -258,6 +298,10 @@ func (s *Store) GetDevice(projectID, id int64) (*Device, error) { v := frame.Int64 d.FrameID = &v } + if typeID.Valid { + v := typeID.Int64 + d.TypeID = &v + } if ex.Valid { d.ExcalidrawID = &ex.String } @@ -277,13 +321,13 @@ func (s *Store) ListDevices(projectID int64, frameID *int64) ([]Device, error) { ) if frameID != nil { rows, err = s.db.Query( - `SELECT id, project_id, frame_id, name, color, x, y, width, height, excalidraw_id, created_at, updated_at + `SELECT id, project_id, frame_id, type_id, name, color, x, y, width, height, excalidraw_id, created_at, updated_at FROM devices WHERE project_id = ? AND frame_id = ? ORDER BY created_at, id`, projectID, *frameID, ) } else { rows, err = s.db.Query( - `SELECT id, project_id, frame_id, name, color, x, y, width, height, excalidraw_id, created_at, updated_at + `SELECT id, project_id, frame_id, type_id, name, color, x, y, width, height, excalidraw_id, created_at, updated_at FROM devices WHERE project_id = ? ORDER BY created_at, id`, projectID, ) @@ -295,9 +339,9 @@ func (s *Store) ListDevices(projectID int64, frameID *int64) ([]Device, error) { out := []Device{} for rows.Next() { var d Device - var frame sql.NullInt64 + var frame, typeID sql.NullInt64 var ex sql.NullString - if err := rows.Scan(&d.ID, &d.ProjectID, &frame, &d.Name, &d.Color, &d.X, &d.Y, &d.Width, &d.Height, + if err := rows.Scan(&d.ID, &d.ProjectID, &frame, &typeID, &d.Name, &d.Color, &d.X, &d.Y, &d.Width, &d.Height, &ex, &d.CreatedAt, &d.UpdatedAt); err != nil { return nil, err } @@ -305,6 +349,10 @@ func (s *Store) ListDevices(projectID int64, frameID *int64) ([]Device, error) { v := frame.Int64 d.FrameID = &v } + if typeID.Valid { + v := typeID.Int64 + d.TypeID = &v + } if ex.Valid { d.ExcalidrawID = &ex.String } @@ -363,11 +411,27 @@ func (s *Store) UpdateDevice(projectID, id int64, u DeviceUpdate) (*Device, erro } cur.FrameID = u.FrameID.ID } + if u.TypeID.Set { + if u.TypeID.ID != nil { + dt, err := s.GetDeviceType(*u.TypeID.ID) + if err != nil { + if errors.Is(err, ErrNotFound) { + return nil, fmt.Errorf("%w: device type %d not found", ErrInvalidInput, *u.TypeID.ID) + } + return nil, err + } + if dt.ProjectID != nil && *dt.ProjectID != projectID { + return nil, fmt.Errorf("%w: device type %d is custom to another project", ErrInvalidInput, *u.TypeID.ID) + } + } + cur.TypeID = u.TypeID.ID + } if _, err := s.db.Exec( `UPDATE devices - SET frame_id = ?, name = ?, color = ?, x = ?, y = ?, width = ?, height = ?, updated_at = datetime('now') + SET frame_id = ?, type_id = ?, name = ?, color = ?, x = ?, y = ?, width = ?, height = ?, updated_at = datetime('now') WHERE id = ? AND project_id = ?`, - nullableInt64(cur.FrameID), cur.Name, cur.Color, cur.X, cur.Y, cur.Width, cur.Height, id, projectID, + nullableInt64(cur.FrameID), nullableInt64(cur.TypeID), + cur.Name, cur.Color, cur.X, cur.Y, cur.Width, cur.Height, id, projectID, ); err != nil { return nil, mapWriteErr(err) } diff --git a/internal/db/models.go b/internal/db/models.go index 51fe3c8..be91614 100644 --- a/internal/db/models.go +++ b/internal/db/models.go @@ -34,10 +34,13 @@ type Frame struct { } // Device is a hardware item inside a project, optionally inside a frame. +// v4: type_id (nullable) lets a device inherit its port profile from a +// device_types catalog row. type Device struct { ID int64 `json:"id"` ProjectID int64 `json:"project_id"` FrameID *int64 `json:"frame_id"` // nullable: device "outside" any frame + TypeID *int64 `json:"type_id"` // nullable: freeform device when null Name string `json:"name"` Color string `json:"color"` X float64 `json:"x"` @@ -49,6 +52,49 @@ type Device struct { UpdatedAt string `json:"updated_at"` } +// DeviceType is a catalog row. Built-in rows have ProjectID nil and +// BuiltIn true. Project-custom rows have ProjectID set. +type DeviceType struct { + ID int64 `json:"id"` + ProjectID *int64 `json:"project_id"` + Name string `json:"name"` + Kind string `json:"kind"` + Icon *string `json:"icon,omitempty"` + Description string `json:"description"` + BuiltIn bool `json:"built_in"` + Ports []DeviceTypePort `json:"ports"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +// DeviceTypePort is a row of a type's port profile. The seeder uses +// (cable_type_id, count, label_prefix, edge, sort_order) to lay out +// concrete ports on a freshly-created device. +type DeviceTypePort struct { + ID int64 `json:"id"` + DeviceTypeID int64 `json:"device_type_id"` + CableTypeID int64 `json:"cable_type_id"` + LabelPrefix string `json:"label_prefix"` + Count int `json:"count"` + Edge string `json:"edge"` + SortOrder int `json:"sort_order"` +} + +// Port is a connector on a device. cable_type colour drives the visual +// rendering; ports are instance-owned even when seeded from a type. +type Port struct { + ID int64 `json:"id"` + ProjectID int64 `json:"project_id"` + DeviceID int64 `json:"device_id"` + TypeID int64 `json:"type_id"` // cable type + Label *string `json:"label"` + XOffset float64 `json:"x_offset"` + YOffset float64 `json:"y_offset"` + ExcalidrawID *string `json:"excalidraw_id,omitempty"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + // Snapshot is the editor's one-shot loader payload for a single project. // Arrays for collections still gated by future slices stay non-nil [] so // JSON encodes as [] not null. @@ -56,7 +102,7 @@ type Snapshot struct { Project Project `json:"project"` Frames []Frame `json:"frames"` Devices []Device `json:"devices"` - Ports []any `json:"ports"` + Ports []Port `json:"ports"` Cables []any `json:"cables"` IOMarkers []IOMarker `json:"io_markers"` Bundles []any `json:"bundles"` diff --git a/internal/db/ports.go b/internal/db/ports.go new file mode 100644 index 0000000..3da206c --- /dev/null +++ b/internal/db/ports.go @@ -0,0 +1,180 @@ +package db + +import ( + "database/sql" +) + +// ListPortsForProject returns every port in a project, ordered by +// device_id + id so callers can group cheaply. +func (s *Store) ListPortsForProject(projectID int64) ([]Port, error) { + rows, err := s.db.Query( + `SELECT id, project_id, device_id, type_id, label, x_offset, y_offset, + excalidraw_id, created_at, updated_at + FROM ports WHERE project_id = ? ORDER BY device_id, id`, projectID, + ) + if err != nil { + return nil, err + } + defer rows.Close() + out := []Port{} + for rows.Next() { + var p Port + var label, ex sql.NullString + if err := rows.Scan(&p.ID, &p.ProjectID, &p.DeviceID, &p.TypeID, + &label, &p.XOffset, &p.YOffset, &ex, &p.CreatedAt, &p.UpdatedAt); err != nil { + return nil, err + } + if label.Valid { + v := label.String + p.Label = &v + } + if ex.Valid { + p.ExcalidrawID = &ex.String + } + out = append(out, p) + } + return out, rows.Err() +} + +// seedPortsFromType inserts the ports for a freshly-created device using +// the type's `device_type_ports` profile. Port positions are computed by +// laying out cable-type groups evenly along the configured edge of the +// device, ordered by sort_order. Within a multi-count group the per-port +// spacing is also even. Runs inside the same transaction as the device +// insert so a failure rolls everything back. +// +// Layout strategy (v0): +// - All ports for a given type sit on the type's configured edge. +// - For each edge, compute total port count N (sum of count across +// entries on that edge) and distribute as t_i = (i + 1)/(N+1) along +// the edge length, so ports don't touch the corners. +// - For top/bottom: x_offset = w * t, y_offset = 0 (top) or h (bottom). +// - For left/right: x_offset = 0 (left) or w (right), y_offset = h * t. +// - Labels: '' if count==1, ' N' (1-indexed) if count>1. +// Empty prefix → NULL label. +func (s *Store) seedPortsFromType(tx *sql.Tx, projectID, deviceID, typeID int64, width, height float64) error { + rows, err := tx.Query( + `SELECT cable_type_id, label_prefix, count, edge, sort_order + FROM device_type_ports + WHERE device_type_id = ? + ORDER BY edge, sort_order, id`, typeID, + ) + if err != nil { + return err + } + type pendingPort struct { + cableTypeID int64 + label *string + xOff float64 + yOff float64 + } + // Group rows by edge first; emit per-port y-or-x slots inside each edge. + type groupRow struct { + cableTypeID int64 + labelPrefix string + count int + } + byEdge := map[string][]groupRow{} + for rows.Next() { + var g groupRow + var edge string + var sortOrder int + if err := rows.Scan(&g.cableTypeID, &g.labelPrefix, &g.count, &edge, &sortOrder); err != nil { + rows.Close() + return err + } + byEdge[edge] = append(byEdge[edge], g) + } + if err := rows.Close(); err != nil { + return err + } + if err := rows.Err(); err != nil { + return err + } + + var pending []pendingPort + for _, edge := range []string{"top", "bottom", "left", "right"} { + groups := byEdge[edge] + if len(groups) == 0 { + continue + } + total := 0 + for _, g := range groups { + total += g.count + } + if total == 0 { + continue + } + // Emit ports in group + within-group order. + idx := 0 + for _, g := range groups { + for k := 0; k < g.count; k++ { + t := float64(idx+1) / float64(total+1) + var xOff, yOff float64 + switch edge { + case "top": + xOff, yOff = width*t, 0 + case "bottom": + xOff, yOff = width*t, height + case "left": + xOff, yOff = 0, height*t + case "right": + xOff, yOff = width, height*t + } + var labelPtr *string + if g.labelPrefix != "" { + var lbl string + if g.count == 1 { + lbl = g.labelPrefix + } else { + lbl = g.labelPrefix + " " + itoa(k+1) + } + labelPtr = &lbl + } + pending = append(pending, pendingPort{ + cableTypeID: g.cableTypeID, label: labelPtr, + xOff: xOff, yOff: yOff, + }) + idx++ + } + } + } + + for _, p := range pending { + var labelArg any + if p.label != nil { + labelArg = *p.label + } + if _, err := tx.Exec( + `INSERT INTO ports (project_id, device_id, type_id, label, x_offset, y_offset) + VALUES (?, ?, ?, ?, ?, ?)`, + projectID, deviceID, p.cableTypeID, labelArg, p.xOff, p.yOff, + ); err != nil { + return mapWriteErr(err) + } + } + return nil +} + +// itoa is a tiny non-allocating int-to-string for port labels. +func itoa(i int) string { + if i == 0 { + return "0" + } + buf := [20]byte{} + pos := len(buf) + neg := i < 0 + if neg { + i = -i + } + for i > 0 { + pos-- + buf[pos] = byte('0' + i%10) + i /= 10 + } + if neg { + pos-- + buf[pos] = '-' + } + return string(buf[pos:]) +} diff --git a/internal/db/store.go b/internal/db/store.go index fb3a5da..da89cb2 100644 --- a/internal/db/store.go +++ b/internal/db/store.go @@ -171,11 +171,15 @@ func (s *Store) Snapshot(id int64) (*Snapshot, error) { if err != nil { return nil, err } + ports, err := s.ListPortsForProject(id) + if err != nil { + return nil, err + } return &Snapshot{ Project: *p, Frames: frames, Devices: devices, - Ports: []any{}, + Ports: ports, Cables: []any{}, IOMarkers: ios, Bundles: []any{},