diff --git a/internal/db/io_markers.go b/internal/db/io_markers.go new file mode 100644 index 0000000..afda0ce --- /dev/null +++ b/internal/db/io_markers.go @@ -0,0 +1,180 @@ +package db + +import ( + "database/sql" + "errors" + "fmt" + "strings" +) + +// IOMarker is a wall-outlet terminator inside a project. Mostly Power +// by convention; the schema doesn't enforce it. +type IOMarker struct { + ID int64 `json:"id"` + ProjectID int64 `json:"project_id"` + FrameID *int64 `json:"frame_id"` + Label string `json:"label"` + X float64 `json:"x"` + Y float64 `json:"y"` + ExcalidrawID *string `json:"excalidraw_id,omitempty"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +// IOMarkerCreate is the create-shape. +type IOMarkerCreate struct { + FrameID *int64 + Label string + X float64 + Y float64 +} + +// IOMarkerUpdate is the partial-update shape. project_id deliberately not +// settable; frame_id uses the same tri-state shape as DeviceUpdate.FrameID. +type IOMarkerUpdate struct { + Label *string + FrameID FrameRef + X *float64 + Y *float64 +} + +// CreateIOMarker inserts a new IO marker. If frame_id is set, it must +// reference a frame in the same project. +func (s *Store) CreateIOMarker(projectID int64, m IOMarkerCreate) (*IOMarker, error) { + label := strings.TrimSpace(m.Label) + if label == "" { + label = "IO" + } + if _, err := s.GetProject(projectID); err != nil { + return nil, err + } + if m.FrameID != nil { + if _, err := s.GetFrame(projectID, *m.FrameID); err != nil { + if errors.Is(err, ErrNotFound) { + return nil, fmt.Errorf("%w: frame_id %d not in project %d", ErrInvalidInput, *m.FrameID, projectID) + } + return nil, err + } + } + res, err := s.db.Exec( + `INSERT INTO io_markers (project_id, frame_id, label, x, y) + VALUES (?, ?, ?, ?, ?)`, + projectID, nullableInt64(m.FrameID), label, m.X, m.Y, + ) + if err != nil { + return nil, mapWriteErr(err) + } + id, _ := res.LastInsertId() + return s.GetIOMarker(projectID, id) +} + +// GetIOMarker loads an IO marker, project-scoped. +func (s *Store) GetIOMarker(projectID, id int64) (*IOMarker, error) { + var m IOMarker + var frame sql.NullInt64 + var ex sql.NullString + err := s.db.QueryRow( + `SELECT id, project_id, frame_id, label, x, y, excalidraw_id, created_at, updated_at + FROM io_markers WHERE id = ? AND project_id = ?`, id, projectID, + ).Scan(&m.ID, &m.ProjectID, &frame, &m.Label, &m.X, &m.Y, &ex, &m.CreatedAt, &m.UpdatedAt) + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrNotFound + } + if err != nil { + return nil, err + } + if frame.Valid { + v := frame.Int64 + m.FrameID = &v + } + if ex.Valid { + m.ExcalidrawID = &ex.String + } + return &m, nil +} + +// ListIOMarkers returns every IO marker in a project, ordered by creation. +func (s *Store) ListIOMarkers(projectID int64) ([]IOMarker, error) { + rows, err := s.db.Query( + `SELECT id, project_id, frame_id, label, x, y, excalidraw_id, created_at, updated_at + FROM io_markers WHERE project_id = ? ORDER BY created_at, id`, projectID, + ) + if err != nil { + return nil, err + } + defer rows.Close() + out := []IOMarker{} + for rows.Next() { + var m IOMarker + var frame sql.NullInt64 + var ex sql.NullString + if err := rows.Scan(&m.ID, &m.ProjectID, &frame, &m.Label, &m.X, &m.Y, + &ex, &m.CreatedAt, &m.UpdatedAt); err != nil { + return nil, err + } + if frame.Valid { + v := frame.Int64 + m.FrameID = &v + } + if ex.Valid { + m.ExcalidrawID = &ex.String + } + out = append(out, m) + } + return out, rows.Err() +} + +// UpdateIOMarker applies a partial update. project_id is locked; frame_id +// tri-state mirrors DeviceUpdate.FrameID. +func (s *Store) UpdateIOMarker(projectID, id int64, u IOMarkerUpdate) (*IOMarker, error) { + cur, err := s.GetIOMarker(projectID, id) + if err != nil { + return nil, err + } + if u.Label != nil { + v := strings.TrimSpace(*u.Label) + if v == "" { + return nil, fmt.Errorf("%w: label cannot be empty", ErrInvalidInput) + } + cur.Label = v + } + if u.X != nil { + cur.X = *u.X + } + if u.Y != nil { + cur.Y = *u.Y + } + if u.FrameID.Set { + if u.FrameID.ID != nil { + if _, err := s.GetFrame(projectID, *u.FrameID.ID); err != nil { + if errors.Is(err, ErrNotFound) { + return nil, fmt.Errorf("%w: frame_id %d not in project %d", ErrInvalidInput, *u.FrameID.ID, projectID) + } + return nil, err + } + } + cur.FrameID = u.FrameID.ID + } + if _, err := s.db.Exec( + `UPDATE io_markers + SET frame_id = ?, label = ?, x = ?, y = ?, updated_at = datetime('now') + WHERE id = ? AND project_id = ?`, + nullableInt64(cur.FrameID), cur.Label, cur.X, cur.Y, id, projectID, + ); err != nil { + return nil, mapWriteErr(err) + } + return s.GetIOMarker(projectID, id) +} + +// DeleteIOMarker removes an IO marker from a project. +func (s *Store) DeleteIOMarker(projectID, id int64) error { + if _, err := s.GetIOMarker(projectID, id); err != nil { + return err + } + if _, err := s.db.Exec( + `DELETE FROM io_markers WHERE id = ? AND project_id = ?`, id, projectID, + ); err != nil { + return err + } + return nil +} diff --git a/internal/db/io_markers_test.go b/internal/db/io_markers_test.go new file mode 100644 index 0000000..b1339ec --- /dev/null +++ b/internal/db/io_markers_test.go @@ -0,0 +1,113 @@ +package db + +import ( + "errors" + "testing" +) + +func TestCreateIOMarker_DefaultsLabel(t *testing.T) { + s := newTestStore(t) + p, _ := s.CreateProject("LOFT", "", "") + m, err := s.CreateIOMarker(p.ID, IOMarkerCreate{X: 10, Y: 20}) + if err != nil { + t.Fatalf("create: %v", err) + } + if m.Label != "IO" { + t.Errorf("default label = %q, want IO", m.Label) + } + if m.FrameID != nil { + t.Errorf("frame_id = %v, want nil", m.FrameID) + } +} + +func TestCreateIOMarker_CustomLabel(t *testing.T) { + s := newTestStore(t) + p, _ := s.CreateProject("LOFT", "", "") + m, err := s.CreateIOMarker(p.ID, IOMarkerCreate{Label: "Wall A", X: 0, Y: 0}) + if err != nil { + t.Fatalf("create: %v", err) + } + if m.Label != "Wall A" { + t.Errorf("label = %q, want Wall A", m.Label) + } +} + +func TestCreateIOMarker_CrossProjectFrameRejected(t *testing.T) { + s := newTestStore(t) + p1, _ := s.CreateProject("LOFT", "", "") + p2, _ := s.CreateProject("OFFICE", "", "") + f2, _ := s.CreateFrame(p2.ID, FrameCreate{Name: "desk", Width: 100, Height: 50}) + _, err := s.CreateIOMarker(p1.ID, IOMarkerCreate{FrameID: &f2.ID, X: 0, Y: 0}) + if !errors.Is(err, ErrInvalidInput) { + t.Errorf("cross-project frame_id should ErrInvalidInput; got %v", err) + } +} + +func TestGetIOMarker_WrongProjectIsNotFound(t *testing.T) { + s := newTestStore(t) + p1, _ := s.CreateProject("LOFT", "", "") + p2, _ := s.CreateProject("OFFICE", "", "") + m, _ := s.CreateIOMarker(p1.ID, IOMarkerCreate{X: 0, Y: 0}) + if _, err := s.GetIOMarker(p2.ID, m.ID); !errors.Is(err, ErrNotFound) { + t.Errorf("cross-project GetIOMarker should be ErrNotFound; got %v", err) + } +} + +func TestUpdateIOMarker_FrameIDTriState(t *testing.T) { + s := newTestStore(t) + p, _ := s.CreateProject("LOFT", "", "") + f, _ := s.CreateFrame(p.ID, FrameCreate{Name: "desk", Width: 100, Height: 50}) + m, _ := s.CreateIOMarker(p.ID, IOMarkerCreate{FrameID: &f.ID, X: 0, Y: 0}) + + // Leave alone — passing a different X must not clear frame_id. + nx := 99.0 + u1, _ := s.UpdateIOMarker(p.ID, m.ID, IOMarkerUpdate{X: &nx}) + if u1.FrameID == nil || *u1.FrameID != f.ID { + t.Errorf("frame_id should still be set (Set=false); got %v", u1.FrameID) + } + + // Clear. + u2, _ := s.UpdateIOMarker(p.ID, m.ID, IOMarkerUpdate{FrameID: FrameRef{Set: true, ID: nil}}) + if u2.FrameID != nil { + t.Errorf("frame_id should be nil after clear; got %v", *u2.FrameID) + } +} + +func TestDeleteFrame_SetsIOMarkerFrameIDToNull(t *testing.T) { + s := newTestStore(t) + p, _ := s.CreateProject("LOFT", "", "") + f, _ := s.CreateFrame(p.ID, FrameCreate{Name: "desk", Width: 800, Height: 600}) + m, _ := s.CreateIOMarker(p.ID, IOMarkerCreate{FrameID: &f.ID, X: 10, Y: 20}) + if m.FrameID == nil { + t.Fatalf("pre-condition: io marker should have frame_id") + } + if err := s.DeleteFrame(p.ID, f.ID); err != nil { + t.Fatalf("delete frame: %v", err) + } + m2, _ := s.GetIOMarker(p.ID, m.ID) + if m2.FrameID != nil { + t.Errorf("io marker frame_id post-delete = %v, want nil (SET NULL)", m2.FrameID) + } +} + +func TestSnapshot_PopulatesIOMarkers(t *testing.T) { + s := newTestStore(t) + p, _ := s.CreateProject("LOFT", "", "") + _, _ = s.CreateIOMarker(p.ID, IOMarkerCreate{Label: "Wall A", X: 10, Y: 20}) + _, _ = s.CreateIOMarker(p.ID, IOMarkerCreate{Label: "UPS rear", X: 100, Y: 200}) + snap, err := s.Snapshot(p.ID) + if err != nil { + t.Fatalf("snapshot: %v", err) + } + if len(snap.IOMarkers) != 2 { + t.Errorf("io_markers len = %d, want 2", len(snap.IOMarkers)) + } +} + +func TestDeleteIOMarker_NotFound(t *testing.T) { + s := newTestStore(t) + p, _ := s.CreateProject("LOFT", "", "") + if err := s.DeleteIOMarker(p.ID, 999); !errors.Is(err, ErrNotFound) { + t.Errorf("got %v, want ErrNotFound", err) + } +} diff --git a/internal/db/models.go b/internal/db/models.go index 228ec4d..51fe3c8 100644 --- a/internal/db/models.go +++ b/internal/db/models.go @@ -58,7 +58,7 @@ type Snapshot struct { Devices []Device `json:"devices"` Ports []any `json:"ports"` Cables []any `json:"cables"` - IOMarkers []any `json:"io_markers"` + IOMarkers []IOMarker `json:"io_markers"` Bundles []any `json:"bundles"` CableTypes []CableType `json:"cable_types"` } diff --git a/internal/db/store.go b/internal/db/store.go index c6d6056..fb3a5da 100644 --- a/internal/db/store.go +++ b/internal/db/store.go @@ -167,13 +167,17 @@ func (s *Store) Snapshot(id int64) (*Snapshot, error) { if err != nil { return nil, err } + ios, err := s.ListIOMarkers(id) + if err != nil { + return nil, err + } return &Snapshot{ Project: *p, Frames: frames, Devices: devices, Ports: []any{}, Cables: []any{}, - IOMarkers: []any{}, + IOMarkers: ios, Bundles: []any{}, CableTypes: types, }, nil