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 }