package db import ( "database/sql" "errors" "fmt" ) // ConnectionRequirementCreate is the create-shape. Server normalises // from/to into (pair_lo, pair_hi) so (A,B,T) and (B,A,T) collide. type ConnectionRequirementCreate struct { FromDeviceID int64 ToDeviceID int64 PreferredCableTypeID *int64 MustConnect *bool // pointer so "absent" defaults to true Notes string } // ConnectionRequirementUpdate is the partial-update shape. project_id + // the device pair are immutable post-create (changing either is best // modelled as delete-then-create — keeps pair_lo/pair_hi semantics simple). type ConnectionRequirementUpdate struct { PreferredCableTypeID FrameRef // tri-state: leave / set / clear MustConnect *bool Notes *string } // CreateConnectionRequirement inserts a new requirement. Validates that // both devices live in projectID, that from != to, and that the // (project, pair_lo, pair_hi, preferred_cable_type_id) tuple is unique. func (s *Store) CreateConnectionRequirement(projectID int64, r ConnectionRequirementCreate) (*ConnectionRequirement, error) { if r.FromDeviceID == r.ToDeviceID { return nil, fmt.Errorf("%w: from_device_id and to_device_id must differ", ErrInvalidInput) } if _, err := s.GetProject(projectID); err != nil { return nil, err } if _, err := s.GetDevice(projectID, r.FromDeviceID); err != nil { if errors.Is(err, ErrNotFound) { return nil, fmt.Errorf("%w: from_device_id %d not in project %d", ErrInvalidInput, r.FromDeviceID, projectID) } return nil, err } if _, err := s.GetDevice(projectID, r.ToDeviceID); err != nil { if errors.Is(err, ErrNotFound) { return nil, fmt.Errorf("%w: to_device_id %d not in project %d", ErrInvalidInput, r.ToDeviceID, projectID) } return nil, err } if r.PreferredCableTypeID != nil { if _, err := s.GetCableType(*r.PreferredCableTypeID); err != nil { if errors.Is(err, ErrNotFound) { return nil, fmt.Errorf("%w: preferred_cable_type_id %d not found", ErrInvalidInput, *r.PreferredCableTypeID) } return nil, err } } must := true if r.MustConnect != nil { must = *r.MustConnect } mustInt := 0 if must { mustInt = 1 } lo, hi := r.FromDeviceID, r.ToDeviceID if lo > hi { lo, hi = hi, lo } res, err := s.db.Exec( `INSERT INTO connection_requirements (project_id, from_device_id, to_device_id, preferred_cable_type_id, must_connect, notes, pair_lo, pair_hi) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, projectID, r.FromDeviceID, r.ToDeviceID, nullableInt64(r.PreferredCableTypeID), mustInt, r.Notes, lo, hi, ) if err != nil { return nil, mapWriteErr(err) } id, _ := res.LastInsertId() return s.GetConnectionRequirement(projectID, id) } // GetConnectionRequirement loads one by id, project-scoped. func (s *Store) GetConnectionRequirement(projectID, id int64) (*ConnectionRequirement, error) { var r ConnectionRequirement var ct sql.NullInt64 var must int err := s.db.QueryRow( `SELECT id, project_id, from_device_id, to_device_id, preferred_cable_type_id, must_connect, notes, created_at, updated_at FROM connection_requirements WHERE id = ? AND project_id = ?`, id, projectID, ).Scan(&r.ID, &r.ProjectID, &r.FromDeviceID, &r.ToDeviceID, &ct, &must, &r.Notes, &r.CreatedAt, &r.UpdatedAt) if errors.Is(err, sql.ErrNoRows) { return nil, ErrNotFound } if err != nil { return nil, err } if ct.Valid { v := ct.Int64 r.PreferredCableTypeID = &v } r.MustConnect = must != 0 return &r, nil } // ListConnectionRequirements returns every requirement in a project, // ordered by id (insertion order). func (s *Store) ListConnectionRequirements(projectID int64) ([]ConnectionRequirement, error) { rows, err := s.db.Query( `SELECT id, project_id, from_device_id, to_device_id, preferred_cable_type_id, must_connect, notes, created_at, updated_at FROM connection_requirements WHERE project_id = ? ORDER BY id`, projectID, ) if err != nil { return nil, err } defer rows.Close() out := []ConnectionRequirement{} for rows.Next() { var r ConnectionRequirement var ct sql.NullInt64 var must int if err := rows.Scan(&r.ID, &r.ProjectID, &r.FromDeviceID, &r.ToDeviceID, &ct, &must, &r.Notes, &r.CreatedAt, &r.UpdatedAt); err != nil { return nil, err } if ct.Valid { v := ct.Int64 r.PreferredCableTypeID = &v } r.MustConnect = must != 0 out = append(out, r) } return out, rows.Err() } // UpdateConnectionRequirement applies a partial update. preferred_cable_type_id // uses the FrameRef tri-state; must_connect + notes are plain pointers. // The (from, to) pair is immutable on PATCH — delete + recreate to change. func (s *Store) UpdateConnectionRequirement(projectID, id int64, u ConnectionRequirementUpdate) (*ConnectionRequirement, error) { cur, err := s.GetConnectionRequirement(projectID, id) if err != nil { return nil, err } if u.PreferredCableTypeID.Set { if u.PreferredCableTypeID.ID != nil { if _, err := s.GetCableType(*u.PreferredCableTypeID.ID); err != nil { if errors.Is(err, ErrNotFound) { return nil, fmt.Errorf("%w: preferred_cable_type_id %d not found", ErrInvalidInput, *u.PreferredCableTypeID.ID) } return nil, err } } cur.PreferredCableTypeID = u.PreferredCableTypeID.ID } if u.MustConnect != nil { cur.MustConnect = *u.MustConnect } if u.Notes != nil { cur.Notes = *u.Notes } mustInt := 0 if cur.MustConnect { mustInt = 1 } if _, err := s.db.Exec( `UPDATE connection_requirements SET preferred_cable_type_id = ?, must_connect = ?, notes = ?, updated_at = datetime('now') WHERE id = ? AND project_id = ?`, nullableInt64(cur.PreferredCableTypeID), mustInt, cur.Notes, id, projectID, ); err != nil { return nil, mapWriteErr(err) } return s.GetConnectionRequirement(projectID, id) } // DeleteConnectionRequirement removes a requirement by id, project-scoped. func (s *Store) DeleteConnectionRequirement(projectID, id int64) error { if _, err := s.GetConnectionRequirement(projectID, id); err != nil { return err } if _, err := s.db.Exec( `DELETE FROM connection_requirements WHERE id = ? AND project_id = ?`, id, projectID, ); err != nil { return err } return nil }