package db import ( "database/sql" "errors" "fmt" "strings" ) // Sentinel errors callers can match against. The server layer maps these // to HTTP status codes. var ( ErrNotFound = errors.New("not found") ErrConflict = errors.New("conflict") // UNIQUE violation ErrInUse = errors.New("in use") // cable_type referenced by a cable ErrConfirmName = errors.New("confirm name missing or mismatched") ErrInvalidInput = errors.New("invalid input") ) // ----------------------------------------------------------------------------- // Projects // ----------------------------------------------------------------------------- // CreateProject inserts a new project. drawingName, if empty, defaults to // ".excalidraw". name and drawingName are trimmed; an empty name // after trimming is rejected. func (s *Store) CreateProject(name, drawingName, description string) (*Project, error) { name = strings.TrimSpace(name) drawingName = strings.TrimSpace(drawingName) if name == "" { return nil, fmt.Errorf("%w: name is required", ErrInvalidInput) } if drawingName == "" { drawingName = name + ".excalidraw" } res, err := s.db.Exec( `INSERT INTO projects (name, drawing_name, description) VALUES (?, ?, ?)`, name, drawingName, description, ) if err != nil { return nil, mapWriteErr(err) } id, _ := res.LastInsertId() return s.GetProject(id) } // GetProject loads a project by ID. func (s *Store) GetProject(id int64) (*Project, error) { var p Project err := s.db.QueryRow( `SELECT id, name, drawing_name, description, created_at, updated_at FROM projects WHERE id = ?`, id, ).Scan(&p.ID, &p.Name, &p.DrawingName, &p.Description, &p.CreatedAt, &p.UpdatedAt) if errors.Is(err, sql.ErrNoRows) { return nil, ErrNotFound } if err != nil { return nil, err } return &p, nil } // ListProjects returns every project ordered by name. func (s *Store) ListProjects() ([]Project, error) { rows, err := s.db.Query( `SELECT id, name, drawing_name, description, created_at, updated_at FROM projects ORDER BY name`, ) if err != nil { return nil, err } defer rows.Close() var out []Project for rows.Next() { var p Project if err := rows.Scan(&p.ID, &p.Name, &p.DrawingName, &p.Description, &p.CreatedAt, &p.UpdatedAt); err != nil { return nil, err } out = append(out, p) } return out, rows.Err() } // ProjectUpdate carries partial fields for PATCH. A nil pointer means // "leave this field untouched". type ProjectUpdate struct { Name *string DrawingName *string Description *string } // UpdateProject applies the partial update. Empty struct = no-op (just // bumps updated_at). Empty Name (after trim) is rejected; whitespace-only // DrawingName is treated as "use .excalidraw" — same default as // CreateProject. func (s *Store) UpdateProject(id int64, u ProjectUpdate) (*Project, error) { cur, err := s.GetProject(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.DrawingName != nil { v := strings.TrimSpace(*u.DrawingName) if v == "" { v = cur.Name + ".excalidraw" } cur.DrawingName = v } if u.Description != nil { cur.Description = *u.Description } if _, err := s.db.Exec( `UPDATE projects SET name = ?, drawing_name = ?, description = ?, updated_at = datetime('now') WHERE id = ?`, cur.Name, cur.DrawingName, cur.Description, id, ); err != nil { return nil, mapWriteErr(err) } return s.GetProject(id) } // DeleteProject removes the project (cascading frames, devices, ports, // cables, io_markers, bundles, bundle_cables). confirmName must match the // project's current name; otherwise ErrConfirmName is returned and nothing // is deleted. func (s *Store) DeleteProject(id int64, confirmName string) error { p, err := s.GetProject(id) if err != nil { return err } if confirmName != p.Name { return ErrConfirmName } if _, err := s.db.Exec(`DELETE FROM projects WHERE id = ?`, id); err != nil { return err } return nil } // Snapshot loads the full editor-init payload for one project. Slice 2 // populates frames + devices; ports / cables / io_markers / bundles // still ship empty until their slices land. func (s *Store) Snapshot(id int64) (*Snapshot, error) { p, err := s.GetProject(id) if err != nil { return nil, err } types, err := s.ListCableTypes() if err != nil { return nil, err } frames, err := s.ListFrames(id) if err != nil { return nil, err } devices, err := s.ListDevices(id, nil) if err != nil { return nil, err } ios, err := s.ListIOMarkers(id) if err != nil { return nil, err } ports, err := s.ListPortsForProject(id) if err != nil { return nil, err } reqs, err := s.ListConnectionRequirements(id) if err != nil { return nil, err } cables, err := s.ListCables(id) if err != nil { return nil, err } bundles, err := s.ListBundles(id) if err != nil { return nil, err } clamps, err := s.ListClamps(id) if err != nil { return nil, err } cableClamps, err := s.ListCableClamps(id) if err != nil { return nil, err } return &Snapshot{ Project: *p, Frames: frames, Devices: devices, Ports: ports, Cables: cables, IOMarkers: ios, Bundles: bundles, CableTypes: types, ConnectionRequirements: reqs, Clamps: clamps, CableClamps: cableClamps, }, nil } // ----------------------------------------------------------------------------- // Cable types (global) // ----------------------------------------------------------------------------- // CreateCableType inserts a global cable type. name must be globally unique. func (s *Store) CreateCableType(name, color string) (*CableType, error) { name = strings.TrimSpace(name) color = strings.TrimSpace(color) if name == "" { return nil, fmt.Errorf("%w: name is required", ErrInvalidInput) } if color == "" { return nil, fmt.Errorf("%w: color is required", ErrInvalidInput) } res, err := s.db.Exec( `INSERT INTO cable_types (name, color) VALUES (?, ?)`, name, color, ) if err != nil { return nil, mapWriteErr(err) } id, _ := res.LastInsertId() return s.GetCableType(id) } // GetCableType loads a cable type by ID. func (s *Store) GetCableType(id int64) (*CableType, error) { var t CableType err := s.db.QueryRow( `SELECT id, name, color, created_at, updated_at FROM cable_types WHERE id = ?`, id, ).Scan(&t.ID, &t.Name, &t.Color, &t.CreatedAt, &t.UpdatedAt) if errors.Is(err, sql.ErrNoRows) { return nil, ErrNotFound } if err != nil { return nil, err } return &t, nil } // ListCableTypes returns every cable type ordered by id (insertion order, // so the legend renders in the same order across reloads). func (s *Store) ListCableTypes() ([]CableType, error) { rows, err := s.db.Query( `SELECT id, name, color, created_at, updated_at FROM cable_types ORDER BY id`, ) if err != nil { return nil, err } defer rows.Close() out := []CableType{} for rows.Next() { var t CableType if err := rows.Scan(&t.ID, &t.Name, &t.Color, &t.CreatedAt, &t.UpdatedAt); err != nil { return nil, err } out = append(out, t) } return out, rows.Err() } // CableTypeUpdate is the partial-update shape for PATCH. type CableTypeUpdate struct { Name *string Color *string } // UpdateCableType applies a partial update. func (s *Store) UpdateCableType(id int64, u CableTypeUpdate) (*CableType, error) { cur, err := s.GetCableType(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 _, err := s.db.Exec( `UPDATE cable_types SET name = ?, color = ?, updated_at = datetime('now') WHERE id = ?`, cur.Name, cur.Color, id, ); err != nil { return nil, mapWriteErr(err) } return s.GetCableType(id) } // DeleteCableType removes a cable type. SQLite enforces ON DELETE RESTRICT // from cables.type_id and ports.type_id; we surface that as ErrInUse plus // the count of referencing cables (so the UI can show "blocked by N cables"). func (s *Store) DeleteCableType(id int64) error { if _, err := s.GetCableType(id); err != nil { return err } if _, err := s.db.Exec(`DELETE FROM cable_types WHERE id = ?`, id); err != nil { if isForeignKeyConstraint(err) { return ErrInUse } return err } return nil } // CountCablesUsingType returns how many cables reference this cable_type. // Used by the server to enrich a 409 InUse response with a helpful number. func (s *Store) CountCablesUsingType(id int64) (int, error) { var n int err := s.db.QueryRow(`SELECT COUNT(*) FROM cables WHERE type_id = ?`, id).Scan(&n) return n, err } // ----------------------------------------------------------------------------- // Error mapping // ----------------------------------------------------------------------------- // mapWriteErr classifies SQLite write errors into our sentinel errors so // the handler layer can pick the right HTTP status. Falls through to the // raw error for anything we don't recognise. func mapWriteErr(err error) error { if err == nil { return nil } msg := err.Error() switch { case strings.Contains(msg, "UNIQUE constraint failed"): return fmt.Errorf("%w: %s", ErrConflict, msg) case strings.Contains(msg, "FOREIGN KEY constraint failed"): return fmt.Errorf("%w: %s", ErrInUse, msg) case strings.Contains(msg, "CHECK constraint failed"): return fmt.Errorf("%w: %s", ErrInvalidInput, msg) } return err } func isForeignKeyConstraint(err error) bool { return err != nil && strings.Contains(err.Error(), "FOREIGN KEY constraint failed") }