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 }