Catalog: 11 built-ins from §2.2 + the v4.1 trio (Screen, Keyboard, Mouse) seeded in migration 002, totalling 16 built-in types. Store layer: - internal/db/device_types.go — CRUD for device_types. Built-ins (project_id NULL) reject PATCH/DELETE with new ErrForbidden sentinel (handler maps to HTTP 403). Project-custom types accept full CRUD; cross-project access returns ErrNotFound. Replacing the port profile on UPDATE is one transaction. - internal/db/ports.go — ListPortsForProject for the snapshot loader + seedPortsFromType(tx, …) used by CreateDevice. Layout is "evenly spaced along the configured edge", per-edge group ordering by sort_order + id. Labels are "<prefix>" for count==1 and "<prefix> N" 1-indexed for count>1. - Device gains a nullable TypeID + tri-state on UpdateDevice. CreateDevice validates the type is built-in or a project-custom row of the same project, then seeds the device's ports in the same transaction. Snapshot now populates Ports from the store; field type tightened to []Port. Tests (15 new, all green with -race): - 16 built-ins seeded with correct names + project_id=NULL + built_in=1 - Port-profile totals match the §2.2 table for every built-in type - Project-custom create + name-collision-with-built-in → 409 (ErrConflict) - Per-project name UNIQUE — same custom name across projects is fine - PATCH/DELETE built-in → ErrForbidden - Cross-project custom PATCH → ErrNotFound - CreateDevice with NAS type → 2 ports along bottom edge, evenly spaced, labels set - CreateDevice with PC type → 5 ports incl. "USB 1" + "USB 2" - CreateDevice without type_id → 0 ports (freeform fallback) - Cross-project custom type on CreateDevice → ErrInvalidInput - Snapshot includes the seeded ports
462 lines
13 KiB
Go
462 lines
13 KiB
Go
package db
|
|
|
|
import (
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
)
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Frames
|
|
// -----------------------------------------------------------------------------
|
|
|
|
// FrameCreate is the create-shape; x/y/width/height carry full positions.
|
|
type FrameCreate struct {
|
|
Name string
|
|
X float64
|
|
Y float64
|
|
Width float64
|
|
Height float64
|
|
}
|
|
|
|
// FrameUpdate is the partial-update shape for PATCH. project_id is
|
|
// deliberately absent — moving a frame across projects would orphan its
|
|
// devices' frame_id refs, so the API refuses to do it.
|
|
type FrameUpdate struct {
|
|
Name *string
|
|
X *float64
|
|
Y *float64
|
|
Width *float64
|
|
Height *float64
|
|
}
|
|
|
|
// CreateFrame inserts a new frame inside a project.
|
|
func (s *Store) CreateFrame(projectID int64, f FrameCreate) (*Frame, error) {
|
|
name := strings.TrimSpace(f.Name)
|
|
if name == "" {
|
|
return nil, fmt.Errorf("%w: name is required", ErrInvalidInput)
|
|
}
|
|
if f.Width <= 0 || f.Height <= 0 {
|
|
return nil, fmt.Errorf("%w: width and height must be positive", ErrInvalidInput)
|
|
}
|
|
if _, err := s.GetProject(projectID); err != nil {
|
|
return nil, err
|
|
}
|
|
res, err := s.db.Exec(
|
|
`INSERT INTO frames (project_id, name, x, y, width, height)
|
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
|
projectID, name, f.X, f.Y, f.Width, f.Height,
|
|
)
|
|
if err != nil {
|
|
return nil, mapWriteErr(err)
|
|
}
|
|
id, _ := res.LastInsertId()
|
|
return s.GetFrame(projectID, id)
|
|
}
|
|
|
|
// GetFrame loads a frame, enforcing project_id scoping.
|
|
func (s *Store) GetFrame(projectID, id int64) (*Frame, error) {
|
|
var f Frame
|
|
var ex sql.NullString
|
|
err := s.db.QueryRow(
|
|
`SELECT id, project_id, name, x, y, width, height, excalidraw_id, created_at, updated_at
|
|
FROM frames WHERE id = ? AND project_id = ?`, id, projectID,
|
|
).Scan(&f.ID, &f.ProjectID, &f.Name, &f.X, &f.Y, &f.Width, &f.Height,
|
|
&ex, &f.CreatedAt, &f.UpdatedAt)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil, ErrNotFound
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if ex.Valid {
|
|
f.ExcalidrawID = &ex.String
|
|
}
|
|
return &f, nil
|
|
}
|
|
|
|
// ListFrames returns every frame in a project, ordered by created_at so
|
|
// the on-screen z-order is stable.
|
|
func (s *Store) ListFrames(projectID int64) ([]Frame, error) {
|
|
rows, err := s.db.Query(
|
|
`SELECT id, project_id, name, x, y, width, height, excalidraw_id, created_at, updated_at
|
|
FROM frames WHERE project_id = ? ORDER BY created_at, id`, projectID,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
out := []Frame{}
|
|
for rows.Next() {
|
|
var f Frame
|
|
var ex sql.NullString
|
|
if err := rows.Scan(&f.ID, &f.ProjectID, &f.Name, &f.X, &f.Y, &f.Width, &f.Height,
|
|
&ex, &f.CreatedAt, &f.UpdatedAt); err != nil {
|
|
return nil, err
|
|
}
|
|
if ex.Valid {
|
|
f.ExcalidrawID = &ex.String
|
|
}
|
|
out = append(out, f)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// UpdateFrame applies a partial update. project_id stays the same — we
|
|
// don't expose moving a frame across projects.
|
|
func (s *Store) UpdateFrame(projectID, id int64, u FrameUpdate) (*Frame, error) {
|
|
cur, err := s.GetFrame(projectID, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
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.X != nil {
|
|
cur.X = *u.X
|
|
}
|
|
if u.Y != nil {
|
|
cur.Y = *u.Y
|
|
}
|
|
if u.Width != nil {
|
|
if *u.Width <= 0 {
|
|
return nil, fmt.Errorf("%w: width must be positive", ErrInvalidInput)
|
|
}
|
|
cur.Width = *u.Width
|
|
}
|
|
if u.Height != nil {
|
|
if *u.Height <= 0 {
|
|
return nil, fmt.Errorf("%w: height must be positive", ErrInvalidInput)
|
|
}
|
|
cur.Height = *u.Height
|
|
}
|
|
if _, err := s.db.Exec(
|
|
`UPDATE frames
|
|
SET name = ?, x = ?, y = ?, width = ?, height = ?, updated_at = datetime('now')
|
|
WHERE id = ? AND project_id = ?`,
|
|
cur.Name, cur.X, cur.Y, cur.Width, cur.Height, id, projectID,
|
|
); err != nil {
|
|
return nil, mapWriteErr(err)
|
|
}
|
|
return s.GetFrame(projectID, id)
|
|
}
|
|
|
|
// DeleteFrame removes a frame. Devices with `frame_id = id` keep existing
|
|
// — the schema's ON DELETE SET NULL drops their frame_id to NULL so they
|
|
// stay in the project as "outside a frame".
|
|
func (s *Store) DeleteFrame(projectID, id int64) error {
|
|
if _, err := s.GetFrame(projectID, id); err != nil {
|
|
return err
|
|
}
|
|
if _, err := s.db.Exec(
|
|
`DELETE FROM frames WHERE id = ? AND project_id = ?`, id, projectID,
|
|
); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Devices
|
|
// -----------------------------------------------------------------------------
|
|
|
|
// 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
|
|
Width float64
|
|
Height float64
|
|
}
|
|
|
|
// 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. 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
|
|
Width *float64
|
|
Height *float64
|
|
}
|
|
|
|
// FrameRef encodes a tri-state for the FrameID PATCH:
|
|
//
|
|
// Set=false → leave the field untouched
|
|
// Set=true, ID=nil → set to NULL (device leaves all frames)
|
|
// Set=true, ID=&someInt → set to that frame id (must be in same project)
|
|
type FrameRef struct {
|
|
Set bool
|
|
ID *int64
|
|
}
|
|
|
|
// CreateDevice inserts a new device. FrameID, if provided, must reference
|
|
// 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 == "" {
|
|
return nil, fmt.Errorf("%w: name is required", ErrInvalidInput)
|
|
}
|
|
if d.Width <= 0 || d.Height <= 0 {
|
|
return nil, fmt.Errorf("%w: width and height must be positive", ErrInvalidInput)
|
|
}
|
|
if _, err := s.GetProject(projectID); err != nil {
|
|
return nil, err
|
|
}
|
|
if d.FrameID != nil {
|
|
if _, err := s.GetFrame(projectID, *d.FrameID); err != nil {
|
|
if errors.Is(err, ErrNotFound) {
|
|
return nil, fmt.Errorf("%w: frame_id %d not in project %d", ErrInvalidInput, *d.FrameID, projectID)
|
|
}
|
|
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"
|
|
}
|
|
|
|
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)
|
|
}
|
|
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, typeID sql.NullInt64
|
|
var ex sql.NullString
|
|
err := s.db.QueryRow(
|
|
`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, &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
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if frame.Valid {
|
|
v := frame.Int64
|
|
d.FrameID = &v
|
|
}
|
|
if typeID.Valid {
|
|
v := typeID.Int64
|
|
d.TypeID = &v
|
|
}
|
|
if ex.Valid {
|
|
d.ExcalidrawID = &ex.String
|
|
}
|
|
return &d, nil
|
|
}
|
|
|
|
// ListDevices returns devices in a project. If frameID is non-nil and
|
|
// dereferences to a value, only devices with that frame_id are returned;
|
|
// if frameID dereferences to nil (i.e. caller passed &FrameRef{Set:true,ID:nil})
|
|
// — actually this signature uses *int64 directly: pass nil for "all
|
|
// devices", or pass &someInt for "devices in that frame". The empty-
|
|
// "outside-any-frame" filter isn't exposed yet — slice 2 doesn't need it.
|
|
func (s *Store) ListDevices(projectID int64, frameID *int64) ([]Device, error) {
|
|
var (
|
|
rows *sql.Rows
|
|
err error
|
|
)
|
|
if frameID != nil {
|
|
rows, err = s.db.Query(
|
|
`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, 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,
|
|
)
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
out := []Device{}
|
|
for rows.Next() {
|
|
var d Device
|
|
var frame, typeID sql.NullInt64
|
|
var ex sql.NullString
|
|
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
|
|
}
|
|
if frame.Valid {
|
|
v := frame.Int64
|
|
d.FrameID = &v
|
|
}
|
|
if typeID.Valid {
|
|
v := typeID.Int64
|
|
d.TypeID = &v
|
|
}
|
|
if ex.Valid {
|
|
d.ExcalidrawID = &ex.String
|
|
}
|
|
out = append(out, d)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// UpdateDevice applies a partial update. FrameID is tri-state — see FrameRef.
|
|
// A FrameID set to a non-nil ID must reference a frame in the same project.
|
|
func (s *Store) UpdateDevice(projectID, id int64, u DeviceUpdate) (*Device, error) {
|
|
cur, err := s.GetDevice(projectID, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
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.Color != nil {
|
|
v := strings.TrimSpace(*u.Color)
|
|
if v == "" {
|
|
return nil, fmt.Errorf("%w: color cannot be empty", ErrInvalidInput)
|
|
}
|
|
cur.Color = v
|
|
}
|
|
if u.X != nil {
|
|
cur.X = *u.X
|
|
}
|
|
if u.Y != nil {
|
|
cur.Y = *u.Y
|
|
}
|
|
if u.Width != nil {
|
|
if *u.Width <= 0 {
|
|
return nil, fmt.Errorf("%w: width must be positive", ErrInvalidInput)
|
|
}
|
|
cur.Width = *u.Width
|
|
}
|
|
if u.Height != nil {
|
|
if *u.Height <= 0 {
|
|
return nil, fmt.Errorf("%w: height must be positive", ErrInvalidInput)
|
|
}
|
|
cur.Height = *u.Height
|
|
}
|
|
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 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 = ?, type_id = ?, name = ?, color = ?, x = ?, y = ?, width = ?, height = ?, updated_at = datetime('now')
|
|
WHERE id = ? AND project_id = ?`,
|
|
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)
|
|
}
|
|
return s.GetDevice(projectID, id)
|
|
}
|
|
|
|
// DeleteDevice removes a device from a project.
|
|
func (s *Store) DeleteDevice(projectID, id int64) error {
|
|
if _, err := s.GetDevice(projectID, id); err != nil {
|
|
return err
|
|
}
|
|
if _, err := s.db.Exec(
|
|
`DELETE FROM devices WHERE id = ? AND project_id = ?`, id, projectID,
|
|
); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// nullableInt64 converts a *int64 into a sql.NullInt64 so we can pass it
|
|
// straight into a parameterised query.
|
|
func nullableInt64(p *int64) any {
|
|
if p == nil {
|
|
return nil
|
|
}
|
|
return *p
|
|
}
|