diff --git a/docs/GAP_ANALYSIS.md b/docs/GAP_ANALYSIS.md new file mode 100644 index 0000000..a3aeb1b --- /dev/null +++ b/docs/GAP_ANALYSIS.md @@ -0,0 +1,487 @@ +# Silo Gap Analysis and Revision Control Roadmap + +**Date:** 2026-01-24 +**Status:** Analysis Complete + +--- + +## Executive Summary + +This document analyzes the current state of the Silo project against its specification, identifies documentation and feature gaps, and outlines a roadmap for enhanced revision control capabilities. + +--- + +## 1. Documentation Gap Analysis + +### 1.1 Current Documentation + +| Document | Coverage | Status | +|----------|----------|--------| +| `README.md` | Quick start, overview | Partial (50%) | +| `docs/SPECIFICATION.md` | Design specification | Comprehensive (90%) | +| `docs/STATUS.md` | Development progress | Current but incomplete | +| `silo-spec.md` | Duplicate of SPECIFICATION.md | Redundant | + +### 1.2 Documentation Gaps (Priority Order) + +#### High Priority + +| Gap | Impact | Effort | +|-----|--------|--------| +| **API Reference** | Users cannot integrate programmatically | Medium | +| **Deployment Guide** | Cannot deploy to production | Medium | +| **Database Schema Guide** | Migration troubleshooting difficult | Low | +| **Configuration Reference** | config.yaml options undocumented | Low | + +#### Medium Priority + +| Gap | Impact | Effort | +|-----|--------|--------| +| **User Workflows** | Users lack step-by-step guidance | Medium | +| **FreeCAD Command Reference** | Addon features undiscoverable | Low | +| **Troubleshooting Guide** | Support burden increases | Medium | +| **Developer Setup Guide** | Onboarding friction | Low | + +#### Lower Priority + +| Gap | Impact | Effort | +|-----|--------|--------| +| **CHANGELOG.md** | Version history unclear | Low | +| **Architecture Decision Records** | Design rationale lost | Medium | +| **Integration Guide** | Third-party integration unclear | High | + +### 1.3 Recommended Actions + +1. **Consolidate specs**: Remove `silo-spec.md` duplicate +2. **Create `docs/API.md`**: Full REST endpoint documentation with examples +3. **Create `docs/DEPLOYMENT.md`**: Production deployment guide +4. **Expand README.md**: Add configuration reference section + +--- + +## 2. Current Revision Control Implementation + +### 2.1 What's Implemented + +| Feature | Status | Location | +|---------|--------|----------| +| Append-only revision history | Complete | `internal/db/items.go` | +| Sequential revision numbering | Complete | Database trigger | +| Property snapshots (JSONB) | Complete | `revisions.properties` | +| File versioning (MinIO) | Complete | `internal/storage/` | +| SHA256 checksums | Complete | Captured on upload | +| Revision comments | Complete | `revisions.comment` | +| User attribution | Complete | `revisions.created_by` | +| Property schema versioning | Complete | Migration 005 | +| BOM revision pinning | Complete | `relationships.child_revision` | + +### 2.2 Database Schema + +```sql +-- Current revision schema (migrations/001_initial.sql) +CREATE TABLE revisions ( + id UUID PRIMARY KEY, + item_id UUID REFERENCES items(id) ON DELETE CASCADE, + revision_number INTEGER NOT NULL, + properties JSONB NOT NULL DEFAULT '{}', + file_key TEXT, + file_version TEXT, -- MinIO version ID + file_checksum TEXT, -- SHA256 + file_size BIGINT, + thumbnail_key TEXT, + created_at TIMESTAMPTZ DEFAULT now(), + created_by TEXT, + comment TEXT, + property_schema_version INTEGER DEFAULT 1, + UNIQUE(item_id, revision_number) +); +``` + +### 2.3 API Endpoints + +| Endpoint | Method | Status | +|----------|--------|--------| +| `/api/items/{pn}/revisions` | GET | Implemented | +| `/api/items/{pn}/revisions` | POST | Implemented | +| `/api/items/{pn}/revisions/{rev}` | GET | Implemented | +| `/api/items/{pn}/file` | POST | Implemented | +| `/api/items/{pn}/file` | GET | Implemented (latest) | +| `/api/items/{pn}/file/{rev}` | GET | Implemented | + +### 2.4 FreeCAD Integration + +| Command | Function | Status | +|---------|----------|--------| +| `Silo_Save` | Auto-save + upload | Implemented | +| `Silo_Commit` | Save with comment | Implemented | +| `Silo_Pull` | Download/create | Implemented | +| `Silo_Push` | Batch upload | Implemented | +| `Silo_Info` | View revision history | Implemented | + +--- + +## 3. Revision Control Gaps + +### 3.1 Critical Gaps + +| Gap | Description | Impact | +|-----|-------------|--------| +| **No rollback** | Cannot revert to previous revision | Data recovery difficult | +| **No comparison** | Cannot diff between revisions | Change tracking manual | +| **No locking** | No concurrent edit protection | Multi-user unsafe | +| **No approval workflow** | No release/sign-off process | Quality control gap | + +### 3.2 Important Gaps + +| Gap | Description | Impact | +|-----|-------------|--------| +| **No branching** | Linear history only | No experimental variants | +| **No tagging** | No named milestones | Release tracking manual | +| **No audit log** | Actions not logged separately | Compliance gap | +| **Thumbnail missing** | Schema exists, not populated | No visual preview | + +### 3.3 Nice-to-Have Gaps + +| Gap | Description | Impact | +|-----|-------------|--------| +| **No search** | Cannot search revision comments | Discovery limited | +| **No retention policy** | Revisions never expire | Storage grows unbounded | +| **No delta storage** | Full file per revision | Storage inefficient | +| **No notifications** | No change alerts | Manual monitoring required | + +--- + +## 4. Revision Control Roadmap + +### Phase 1: Foundation (Recommended First) + +**Goal:** Enable safe single-user revision management + +#### 1.1 Rollback Support +``` +Effort: Medium | Priority: High | Risk: Low +``` + +**Changes Required:** +- Add `POST /api/items/{pn}/rollback/{rev}` endpoint +- Create new revision copying properties/file from target revision +- FreeCAD: Add `Silo_Rollback` command + +**Database:** No schema changes needed (creates new revision from old) + +#### 1.2 Revision Comparison API +``` +Effort: Medium | Priority: High | Risk: Low +``` + +**Changes Required:** +- Add `GET /api/items/{pn}/revisions/compare?from={rev1}&to={rev2}` endpoint +- Return property diff (added/removed/changed keys) +- Return file metadata diff (size, checksum changes) + +**Implementation:** +```go +type RevisionDiff struct { + FromRevision int `json:"from_revision"` + ToRevision int `json:"to_revision"` + Properties PropertyDiff `json:"properties"` + FileChanged bool `json:"file_changed"` + FileSizeDiff int64 `json:"file_size_diff,omitempty"` +} + +type PropertyDiff struct { + Added map[string]any `json:"added,omitempty"` + Removed map[string]any `json:"removed,omitempty"` + Changed map[string]PropertyChange `json:"changed,omitempty"` +} +``` + +#### 1.3 Revision Labels/Status +``` +Effort: Low | Priority: Medium | Risk: Low +``` + +**Database Migration:** +```sql +ALTER TABLE revisions ADD COLUMN status TEXT DEFAULT 'draft'; +-- Values: 'draft', 'review', 'released', 'obsolete' + +ALTER TABLE revisions ADD COLUMN labels TEXT[] DEFAULT '{}'; +-- Arbitrary tags: ['prototype', 'v1.0', 'customer-approved'] +``` + +**API Changes:** +- Add `PATCH /api/items/{pn}/revisions/{rev}` for status/label updates +- Add filtering by status in list endpoint + +--- + +### Phase 2: Multi-User Support + +**Goal:** Enable safe concurrent editing + +#### 2.1 Pessimistic Locking +``` +Effort: High | Priority: High | Risk: Medium +``` + +**Database Migration:** +```sql +CREATE TABLE item_locks ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + item_id UUID NOT NULL REFERENCES items(id) ON DELETE CASCADE, + locked_by TEXT NOT NULL, + locked_at TIMESTAMPTZ NOT NULL DEFAULT now(), + expires_at TIMESTAMPTZ NOT NULL, + lock_type TEXT NOT NULL DEFAULT 'exclusive', + comment TEXT, + UNIQUE(item_id) +); + +CREATE INDEX idx_locks_expires ON item_locks(expires_at); +``` + +**API Endpoints:** +``` +POST /api/items/{pn}/lock # Acquire lock +DELETE /api/items/{pn}/lock # Release lock +GET /api/items/{pn}/lock # Check lock status +``` + +**FreeCAD Integration:** +- Auto-lock on `Silo_Pull` (configurable) +- Auto-unlock on `Silo_Save`/`Silo_Commit` +- Show lock status in `Silo_Info` + +#### 2.2 Authentication (LDAP/FreeIPA) +``` +Effort: High | Priority: High | Risk: Medium +``` + +**Changes Required:** +- Add `internal/auth/` package +- LDAP bind configuration in config.yaml +- Middleware for API authentication +- `created_by` populated from authenticated user + +**Configuration:** +```yaml +auth: + enabled: true + provider: ldap + ldap: + server: ldap://freeipa.example.com + base_dn: cn=users,cn=accounts,dc=example,dc=com + bind_dn: uid=silo-service,cn=users,... + bind_password_env: LDAP_BIND_PASSWORD +``` + +#### 2.3 Audit Logging +``` +Effort: Medium | Priority: Medium | Risk: Low +``` + +**Database Migration:** +```sql +CREATE TABLE audit_log ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + timestamp TIMESTAMPTZ NOT NULL DEFAULT now(), + user_id TEXT NOT NULL, + action TEXT NOT NULL, -- 'create', 'update', 'delete', 'lock', 'unlock' + resource_type TEXT NOT NULL, -- 'item', 'revision', 'project', 'relationship' + resource_id TEXT NOT NULL, + details JSONB, + ip_address TEXT +); + +CREATE INDEX idx_audit_timestamp ON audit_log(timestamp DESC); +CREATE INDEX idx_audit_user ON audit_log(user_id); +CREATE INDEX idx_audit_resource ON audit_log(resource_type, resource_id); +``` + +--- + +### Phase 3: Advanced Features + +**Goal:** Enhanced revision management capabilities + +#### 3.1 Branching Support +``` +Effort: High | Priority: Low | Risk: High +``` + +**Concept:** Named revision streams per item + +**Database Migration:** +```sql +CREATE TABLE revision_branches ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + item_id UUID NOT NULL REFERENCES items(id) ON DELETE CASCADE, + name TEXT NOT NULL, -- 'main', 'experimental', 'customer-variant' + base_revision INTEGER, -- Branch point + created_at TIMESTAMPTZ DEFAULT now(), + created_by TEXT, + UNIQUE(item_id, name) +); + +ALTER TABLE revisions ADD COLUMN branch_id UUID REFERENCES revision_branches(id); +``` + +**Complexity:** Requires merge logic, conflict resolution UI + +#### 3.2 Release Management +``` +Effort: Medium | Priority: Medium | Risk: Low +``` + +**Database Migration:** +```sql +CREATE TABLE releases ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + name TEXT NOT NULL UNIQUE, -- 'v1.0', '2026-Q1' + description TEXT, + created_at TIMESTAMPTZ DEFAULT now(), + created_by TEXT, + status TEXT DEFAULT 'draft' -- 'draft', 'released', 'archived' +); + +CREATE TABLE release_items ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + release_id UUID REFERENCES releases(id) ON DELETE CASCADE, + item_id UUID REFERENCES items(id) ON DELETE CASCADE, + revision_number INTEGER NOT NULL, + UNIQUE(release_id, item_id) +); +``` + +**Use Case:** Snapshot a set of items at specific revisions for a product release + +#### 3.3 Thumbnail Generation +``` +Effort: Medium | Priority: Low | Risk: Low +``` + +**Implementation Options:** +1. FreeCAD headless rendering (python script) +2. External service (e.g., CAD thumbnail microservice) +3. User-uploaded thumbnails + +**Changes:** +- Add thumbnail generation on file upload +- Store in MinIO at `thumbnails/{part_number}/rev{n}.png` +- Expose via `GET /api/items/{pn}/thumbnail/{rev}` + +--- + +## 5. Recommended Implementation Order + +### Immediate (Next Sprint) + +1. **Revision Comparison API** - High value, low risk +2. **Rollback Support** - Critical for data safety +3. **Revision Labels** - Quick win for workflow + +### Short-term (1-2 Months) + +4. **Pessimistic Locking** - Required before multi-user +5. **Authentication** - Required before production deployment +6. **Audit Logging** - Compliance and debugging + +### Medium-term (3-6 Months) + +7. **Release Management** - Product milestone tracking +8. **Thumbnail Generation** - Visual preview capability +9. **Documentation Overhaul** - API reference, deployment guide + +### Long-term (Future) + +10. **Branching** - Complex, defer until needed +11. **Delta Storage** - Optimization, not critical +12. **Notifications** - Nice-to-have workflow enhancement + +--- + +## 6. Migration Considerations + +### Part Number Format Migration (Completed) + +The recent migration from `XXXXX-CCC-NNNN` to `CCC-NNNN` format has been completed: + +- Database migration: `migrations/006_project_tags.sql` +- Schema update: `schemas/kindred-rd.yaml` v3 +- Projects: Now many-to-many tags instead of embedded in part number +- File paths: `~/projects/cad/{category}_{name}/{part_number}_{description}.FCStd` + +### Future Schema Migrations + +The property schema versioning framework (`property_schema_version`, `property_migrations` table) is in place but lacks: + +- Automated migration runners +- Rollback capability for failed migrations +- Dry-run validation mode + +**Recommendation:** Build migration tooling before adding complex property schemas. + +--- + +## 7. Open Questions (from Specification) + +These design decisions remain unresolved: + +1. **Property change triggers** - Should editing properties auto-create revision? +2. **Revision metadata editing** - Allow comment updates post-creation? +3. **Soft delete behavior** - Archive or hard delete revisions? +4. **File diff strategy** - Exploded FCStd storage for better diffing? +5. **Retention policy** - How long to keep old revisions? + +--- + +## Appendix A: File Structure for New Features + +``` +internal/ + api/ + handlers_revision.go # New revision endpoints + handlers_lock.go # Locking endpoints + handlers_audit.go # Audit log endpoints + auth/ + ldap.go # LDAP authentication + middleware.go # Auth middleware + db/ + locks.go # Lock repository + audit.go # Audit repository + releases.go # Release repository +migrations/ + 007_revision_status.sql # Labels and status + 008_item_locks.sql # Locking table + 009_audit_log.sql # Audit logging + 010_releases.sql # Release management +``` + +--- + +## Appendix B: API Additions Summary + +### Phase 1 Endpoints +``` +GET /api/items/{pn}/revisions/compare # Diff two revisions +POST /api/items/{pn}/rollback/{rev} # Create revision from old +PATCH /api/items/{pn}/revisions/{rev} # Update status/labels +``` + +### Phase 2 Endpoints +``` +POST /api/items/{pn}/lock # Acquire lock +DELETE /api/items/{pn}/lock # Release lock +GET /api/items/{pn}/lock # Check lock status +GET /api/audit # Query audit log +``` + +### Phase 3 Endpoints +``` +GET /api/releases # List releases +POST /api/releases # Create release +GET /api/releases/{name} # Get release details +POST /api/releases/{name}/items # Add items to release +GET /api/items/{pn}/thumbnail/{rev} # Get thumbnail +``` diff --git a/internal/api/csv.go b/internal/api/csv.go index b34ae55..7208483 100644 --- a/internal/api/csv.go +++ b/internal/api/csv.go @@ -47,8 +47,8 @@ var csvColumns = []string{ "current_revision", "created_at", "updated_at", - "project", "category", + "projects", // comma-separated project codes } // HandleExportCSV exports items to CSV format. @@ -129,8 +129,19 @@ func (s *Server) HandleExportCSV(w http.ResponseWriter, r *http.Request) { for _, item := range items { row := make([]string, len(headers)) - // Parse part number to extract project and category - project, category := parsePartNumber(item.PartNumber) + // Extract category from part number (format: CCC-NNNN) + category := parseCategory(item.PartNumber) + + // Get projects for this item + projects, err := s.projects.GetProjectsForItem(ctx, item.ID) + projectCodes := "" + if err == nil && len(projects) > 0 { + codes := make([]string, len(projects)) + for i, p := range projects { + codes[i] = p.Code + } + projectCodes = strings.Join(codes, ",") + } // Standard columns row[0] = item.PartNumber @@ -139,8 +150,8 @@ func (s *Server) HandleExportCSV(w http.ResponseWriter, r *http.Request) { row[3] = strconv.Itoa(item.CurrentRevision) row[4] = item.CreatedAt.Format(time.RFC3339) row[5] = item.UpdatedAt.Format(time.RFC3339) - row[6] = project - row[7] = category + row[6] = category + row[7] = projectCodes // Property columns if includeProps { @@ -206,8 +217,8 @@ func (s *Server) HandleImportCSV(w http.ResponseWriter, r *http.Request) { colIndex[strings.ToLower(strings.TrimSpace(h))] = i } - // Validate required columns - requiredCols := []string{"project", "category"} + // Validate required columns - only category is required now (projects are optional tags) + requiredCols := []string{"category"} for _, col := range requiredCols { if _, ok := colIndex[col]; !ok { writeError(w, http.StatusBadRequest, "missing_column", fmt.Sprintf("Required column '%s' not found", col)) @@ -241,20 +252,20 @@ func (s *Server) HandleImportCSV(w http.ResponseWriter, r *http.Request) { rowNum++ // Extract values - project := getCSVValue(record, colIndex, "project") category := getCSVValue(record, colIndex, "category") description := getCSVValue(record, colIndex, "description") partNumber := getCSVValue(record, colIndex, "part_number") + projectsStr := getCSVValue(record, colIndex, "projects") - // Validate project - if project == "" { - result.Errors = append(result.Errors, CSVImportErr{ - Row: rowNum, - Field: "project", - Message: "Project code is required", - }) - result.ErrorCount++ - continue + // Parse project codes (comma-separated) + var projectCodes []string + if projectsStr != "" { + for _, code := range strings.Split(projectsStr, ",") { + code = strings.TrimSpace(strings.ToUpper(code)) + if code != "" { + projectCodes = append(projectCodes, code) + } + } } // Validate category @@ -270,7 +281,6 @@ func (s *Server) HandleImportCSV(w http.ResponseWriter, r *http.Request) { // Build properties from extra columns properties := make(map[string]any) - properties["project"] = strings.ToUpper(project) properties["category"] = strings.ToUpper(category) for col, idx := range colIndex { @@ -308,7 +318,6 @@ func (s *Server) HandleImportCSV(w http.ResponseWriter, r *http.Request) { input := partnum.Input{ SchemaName: schemaName, Values: map[string]string{ - "project": strings.ToUpper(project), "category": strings.ToUpper(category), }, } @@ -351,6 +360,18 @@ func (s *Server) HandleImportCSV(w http.ResponseWriter, r *http.Request) { continue } + // Tag item with projects + if len(projectCodes) > 0 { + if err := s.projects.SetItemProjects(ctx, item.ID, projectCodes); err != nil { + // Item was created but tagging failed - log warning but don't fail the row + s.logger.Warn(). + Err(err). + Str("part_number", partNumber). + Strs("projects", projectCodes). + Msg("failed to tag item with projects") + } + } + result.SuccessCount++ result.CreatedItems = append(result.CreatedItems, partNumber) } @@ -380,9 +401,9 @@ func (s *Server) HandleCSVTemplate(w http.ResponseWriter, r *http.Request) { // Build headers: standard columns + default property columns from schema headers := []string{ - "project", "category", "description", + "projects", // comma-separated project codes (optional) } // Add default property columns from schema @@ -411,9 +432,9 @@ func (s *Server) HandleCSVTemplate(w http.ResponseWriter, r *http.Request) { // Write example row exampleRow := make([]string, len(headers)) - exampleRow[0] = "PROJ1" // project - exampleRow[1] = "F01" // category - exampleRow[2] = "Example Item Description" + exampleRow[0] = "F01" // category + exampleRow[1] = "Example Item Description" // description + exampleRow[2] = "PROJ1,PROJ2" // projects (comma-separated) // Leave property columns empty if err := writer.Write(exampleRow); err != nil { @@ -424,13 +445,13 @@ func (s *Server) HandleCSVTemplate(w http.ResponseWriter, r *http.Request) { // Helper functions -func parsePartNumber(pn string) (project, category string) { +// parseCategory extracts category code from part number (format: CCC-NNNN) +func parseCategory(pn string) string { parts := strings.Split(pn, "-") - if len(parts) >= 2 { - project = parts[0] - category = parts[1] + if len(parts) >= 1 { + return parts[0] } - return + return "" } func formatPropertyValue(v any) string { @@ -509,8 +530,8 @@ func isStandardColumn(col string) bool { "current_revision": true, "created_at": true, "updated_at": true, - "project": true, "category": true, + "projects": true, } return standardCols[col] } diff --git a/internal/api/handlers.go b/internal/api/handlers.go index 1ca9348..d62fab3 100644 --- a/internal/api/handlers.go +++ b/internal/api/handlers.go @@ -3,6 +3,7 @@ package api import ( "context" "encoding/json" + "fmt" "net/http" "os" "path/filepath" @@ -22,6 +23,7 @@ type Server struct { logger zerolog.Logger db *db.DB items *db.ItemRepository + projects *db.ProjectRepository schemas map[string]*schema.Schema schemasDir string partgen *partnum.Generator @@ -37,6 +39,7 @@ func NewServer( store *storage.Storage, ) *Server { items := db.NewItemRepository(database) + projects := db.NewProjectRepository(database) seqStore := &dbSequenceStore{db: database, schemas: schemas} partgen := partnum.NewGenerator(schemas, seqStore) @@ -44,6 +47,7 @@ func NewServer( logger: logger, db: database, items: items, + projects: projects, schemas: schemas, schemasDir: schemasDir, partgen: partgen, @@ -214,9 +218,9 @@ type ItemResponse struct { // CreateItemRequest represents a request to create an item. type CreateItemRequest struct { Schema string `json:"schema"` - Project string `json:"project"` Category string `json:"category"` Description string `json:"description"` + Projects []string `json:"projects,omitempty"` Properties map[string]any `json:"properties,omitempty"` } @@ -256,20 +260,6 @@ func (s *Server) HandleListItems(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, response) } -// HandleListProjects returns distinct project codes from existing items. -func (s *Server) HandleListProjects(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - - projects, err := s.items.ListProjects(ctx) - if err != nil { - s.logger.Error().Err(err).Msg("failed to list projects") - writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list projects") - return - } - - writeJSON(w, http.StatusOK, projects) -} - // HandleCreateItem creates a new item with generated part number. func (s *Server) HandleCreateItem(w http.ResponseWriter, r *http.Request) { ctx := r.Context() @@ -286,11 +276,10 @@ func (s *Server) HandleCreateItem(w http.ResponseWriter, r *http.Request) { schemaName = "kindred-rd" } - // Generate part number + // Generate part number (no longer includes project) input := partnum.Input{ SchemaName: schemaName, Values: map[string]string{ - "project": req.Project, "category": req.Category, }, } @@ -308,8 +297,6 @@ func (s *Server) HandleCreateItem(w http.ResponseWriter, r *http.Request) { switch req.Category[0] { case 'A': itemType = "assembly" - case 'D': - itemType = "document" case 'T': itemType = "tooling" } @@ -326,7 +313,6 @@ func (s *Server) HandleCreateItem(w http.ResponseWriter, r *http.Request) { if properties == nil { properties = make(map[string]any) } - properties["project"] = req.Project properties["category"] = req.Category if err := s.items.Create(ctx, item, properties); err != nil { @@ -335,6 +321,15 @@ func (s *Server) HandleCreateItem(w http.ResponseWriter, r *http.Request) { return } + // Tag item with projects if provided + if len(req.Projects) > 0 { + for _, projectCode := range req.Projects { + if err := s.projects.AddItemToProjectByCode(ctx, item.ID, projectCode); err != nil { + s.logger.Warn().Err(err).Str("project", projectCode).Msg("failed to tag item with project") + } + } + } + writeJSON(w, http.StatusCreated, itemToResponse(item)) } @@ -494,6 +489,32 @@ type RevisionResponse struct { CreatedAt string `json:"created_at"` CreatedBy *string `json:"created_by,omitempty"` Comment *string `json:"comment,omitempty"` + Status string `json:"status"` + Labels []string `json:"labels"` +} + +// RevisionDiffResponse represents the API response for revision comparison. +type RevisionDiffResponse struct { + FromRevision int `json:"from_revision"` + ToRevision int `json:"to_revision"` + FromStatus string `json:"from_status"` + ToStatus string `json:"to_status"` + FileChanged bool `json:"file_changed"` + FileSizeDiff *int64 `json:"file_size_diff,omitempty"` + Added map[string]any `json:"added,omitempty"` + Removed map[string]any `json:"removed,omitempty"` + Changed map[string]db.PropertyChange `json:"changed,omitempty"` +} + +// UpdateRevisionRequest represents a request to update revision status/labels. +type UpdateRevisionRequest struct { + Status *string `json:"status,omitempty"` + Labels []string `json:"labels,omitempty"` +} + +// RollbackRequest represents a request to rollback to a previous revision. +type RollbackRequest struct { + Comment string `json:"comment,omitempty"` } // HandleListRevisions lists revisions for an item. @@ -561,6 +582,172 @@ func (s *Server) HandleGetRevision(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusNotFound, "not_found", "Revision not found") } +// HandleUpdateRevision updates the status and/or labels of a revision. +func (s *Server) HandleUpdateRevision(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + partNumber := chi.URLParam(r, "partNumber") + revStr := chi.URLParam(r, "revision") + + revNum, err := strconv.Atoi(revStr) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid_revision", "Revision must be a number") + return + } + + item, err := s.items.GetByPartNumber(ctx, partNumber) + if err != nil { + s.logger.Error().Err(err).Msg("failed to get item") + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get item") + return + } + if item == nil { + writeError(w, http.StatusNotFound, "not_found", "Item not found") + return + } + + var req UpdateRevisionRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid_json", err.Error()) + return + } + + // Validate that at least one field is being updated + if req.Status == nil && req.Labels == nil { + writeError(w, http.StatusBadRequest, "invalid_request", "Must provide status or labels to update") + return + } + + err = s.items.UpdateRevisionStatus(ctx, item.ID, revNum, req.Status, req.Labels) + if err != nil { + if err.Error() == "revision not found" { + writeError(w, http.StatusNotFound, "not_found", "Revision not found") + return + } + s.logger.Error().Err(err).Msg("failed to update revision") + writeError(w, http.StatusInternalServerError, "internal_error", err.Error()) + return + } + + // Return updated revision + rev, err := s.items.GetRevision(ctx, item.ID, revNum) + if err != nil || rev == nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get updated revision") + return + } + + writeJSON(w, http.StatusOK, revisionToResponse(rev)) +} + +// HandleCompareRevisions compares two revisions and returns their differences. +func (s *Server) HandleCompareRevisions(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + partNumber := chi.URLParam(r, "partNumber") + + // Get query parameters for from and to revisions + fromStr := r.URL.Query().Get("from") + toStr := r.URL.Query().Get("to") + + if fromStr == "" || toStr == "" { + writeError(w, http.StatusBadRequest, "invalid_request", "Must provide 'from' and 'to' query parameters") + return + } + + fromRev, err := strconv.Atoi(fromStr) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid_revision", "'from' must be a number") + return + } + + toRev, err := strconv.Atoi(toStr) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid_revision", "'to' must be a number") + return + } + + item, err := s.items.GetByPartNumber(ctx, partNumber) + if err != nil { + s.logger.Error().Err(err).Msg("failed to get item") + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get item") + return + } + if item == nil { + writeError(w, http.StatusNotFound, "not_found", "Item not found") + return + } + + diff, err := s.items.CompareRevisions(ctx, item.ID, fromRev, toRev) + if err != nil { + s.logger.Error().Err(err).Msg("failed to compare revisions") + writeError(w, http.StatusBadRequest, "comparison_failed", err.Error()) + return + } + + response := RevisionDiffResponse{ + FromRevision: diff.FromRevision, + ToRevision: diff.ToRevision, + FromStatus: diff.FromStatus, + ToStatus: diff.ToStatus, + FileChanged: diff.FileChanged, + FileSizeDiff: diff.FileSizeDiff, + Added: diff.Added, + Removed: diff.Removed, + Changed: diff.Changed, + } + + writeJSON(w, http.StatusOK, response) +} + +// HandleRollbackRevision creates a new revision by copying from an existing one. +func (s *Server) HandleRollbackRevision(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + partNumber := chi.URLParam(r, "partNumber") + revStr := chi.URLParam(r, "revision") + + revNum, err := strconv.Atoi(revStr) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid_revision", "Revision must be a number") + return + } + + item, err := s.items.GetByPartNumber(ctx, partNumber) + if err != nil { + s.logger.Error().Err(err).Msg("failed to get item") + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get item") + return + } + if item == nil { + writeError(w, http.StatusNotFound, "not_found", "Item not found") + return + } + + var req RollbackRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil && err.Error() != "EOF" { + writeError(w, http.StatusBadRequest, "invalid_json", err.Error()) + return + } + + // Generate comment if not provided + comment := req.Comment + if comment == "" { + comment = fmt.Sprintf("Rollback to revision %d", revNum) + } + + newRev, err := s.items.CreateRevisionFromExisting(ctx, item.ID, revNum, comment, nil) + if err != nil { + s.logger.Error().Err(err).Msg("failed to create rollback revision") + writeError(w, http.StatusBadRequest, "rollback_failed", err.Error()) + return + } + + s.logger.Info(). + Str("part_number", partNumber). + Int("source_revision", revNum). + Int("new_revision", newRev.RevisionNumber). + Msg("rollback revision created") + + writeJSON(w, http.StatusCreated, revisionToResponse(newRev)) +} + // Part number generation // GeneratePartNumberRequest represents a request to generate a part number. @@ -811,6 +998,10 @@ func itemToResponse(item *db.Item) ItemResponse { } func revisionToResponse(rev *db.Revision) RevisionResponse { + labels := rev.Labels + if labels == nil { + labels = []string{} + } return RevisionResponse{ ID: rev.ID, RevisionNumber: rev.RevisionNumber, @@ -821,6 +1012,8 @@ func revisionToResponse(rev *db.Revision) RevisionResponse { CreatedAt: rev.CreatedAt.Format("2006-01-02T15:04:05Z07:00"), CreatedBy: rev.CreatedBy, Comment: rev.Comment, + Status: rev.Status, + Labels: labels, } } @@ -1074,3 +1267,257 @@ func (s *Server) HandleDownloadLatestFile(w http.ResponseWriter, r *http.Request rctx.URLParams.Add("revision", "latest") s.HandleDownloadFile(w, r) } + +// Project handlers + +// ProjectResponse represents a project in API responses. +type ProjectResponse struct { + ID string `json:"id"` + Code string `json:"code"` + Name string `json:"name,omitempty"` + Description string `json:"description,omitempty"` + CreatedAt string `json:"created_at"` +} + +// CreateProjectRequest represents a request to create a project. +type CreateProjectRequest struct { + Code string `json:"code"` + Name string `json:"name,omitempty"` + Description string `json:"description,omitempty"` +} + +// UpdateProjectRequest represents a request to update a project. +type UpdateProjectRequest struct { + Name string `json:"name"` + Description string `json:"description"` +} + +// HandleListProjects lists all projects. +func (s *Server) HandleListProjects(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + projects, err := s.projects.List(ctx) + if err != nil { + s.logger.Error().Err(err).Msg("failed to list projects") + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list projects") + return + } + + response := make([]ProjectResponse, len(projects)) + for i, p := range projects { + response[i] = projectToResponse(p) + } + + writeJSON(w, http.StatusOK, response) +} + +// HandleCreateProject creates a new project. +func (s *Server) HandleCreateProject(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + var req CreateProjectRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid_json", err.Error()) + return + } + + if req.Code == "" { + writeError(w, http.StatusBadRequest, "invalid_request", "Project code is required") + return + } + + // Validate project code format (2-10 alphanumeric characters) + if len(req.Code) < 2 || len(req.Code) > 10 { + writeError(w, http.StatusBadRequest, "invalid_code", "Project code must be 2-10 characters") + return + } + + project := &db.Project{ + Code: req.Code, + Name: req.Name, + Description: req.Description, + } + + if err := s.projects.Create(ctx, project); err != nil { + s.logger.Error().Err(err).Msg("failed to create project") + writeError(w, http.StatusInternalServerError, "create_failed", err.Error()) + return + } + + writeJSON(w, http.StatusCreated, projectToResponse(project)) +} + +// HandleGetProject retrieves a project by code. +func (s *Server) HandleGetProject(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + code := chi.URLParam(r, "code") + + project, err := s.projects.GetByCode(ctx, code) + if err != nil { + s.logger.Error().Err(err).Msg("failed to get project") + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get project") + return + } + if project == nil { + writeError(w, http.StatusNotFound, "not_found", "Project not found") + return + } + + writeJSON(w, http.StatusOK, projectToResponse(project)) +} + +// HandleUpdateProject updates a project. +func (s *Server) HandleUpdateProject(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + code := chi.URLParam(r, "code") + + var req UpdateProjectRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid_json", err.Error()) + return + } + + if err := s.projects.Update(ctx, code, req.Name, req.Description); err != nil { + s.logger.Error().Err(err).Msg("failed to update project") + writeError(w, http.StatusInternalServerError, "update_failed", err.Error()) + return + } + + project, _ := s.projects.GetByCode(ctx, code) + writeJSON(w, http.StatusOK, projectToResponse(project)) +} + +// HandleDeleteProject deletes a project. +func (s *Server) HandleDeleteProject(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + code := chi.URLParam(r, "code") + + if err := s.projects.Delete(ctx, code); err != nil { + s.logger.Error().Err(err).Msg("failed to delete project") + writeError(w, http.StatusInternalServerError, "delete_failed", err.Error()) + return + } + + w.WriteHeader(http.StatusNoContent) +} + +// HandleGetProjectItems lists items in a project. +func (s *Server) HandleGetProjectItems(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + code := chi.URLParam(r, "code") + + project, err := s.projects.GetByCode(ctx, code) + if err != nil || project == nil { + writeError(w, http.StatusNotFound, "not_found", "Project not found") + return + } + + items, err := s.projects.GetItemsForProject(ctx, project.ID) + if err != nil { + s.logger.Error().Err(err).Msg("failed to get project items") + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get items") + return + } + + response := make([]ItemResponse, len(items)) + for i, item := range items { + response[i] = itemToResponse(item) + } + + writeJSON(w, http.StatusOK, response) +} + +// HandleGetItemProjects lists projects for an item. +func (s *Server) HandleGetItemProjects(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + partNumber := chi.URLParam(r, "partNumber") + + item, err := s.items.GetByPartNumber(ctx, partNumber) + if err != nil || item == nil { + writeError(w, http.StatusNotFound, "not_found", "Item not found") + return + } + + projects, err := s.projects.GetProjectsForItem(ctx, item.ID) + if err != nil { + s.logger.Error().Err(err).Msg("failed to get item projects") + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get projects") + return + } + + response := make([]ProjectResponse, len(projects)) + for i, p := range projects { + response[i] = projectToResponse(p) + } + + writeJSON(w, http.StatusOK, response) +} + +// AddItemProjectRequest represents a request to add projects to an item. +type AddItemProjectRequest struct { + Projects []string `json:"projects"` +} + +// HandleAddItemProjects adds project tags to an item. +func (s *Server) HandleAddItemProjects(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + partNumber := chi.URLParam(r, "partNumber") + + item, err := s.items.GetByPartNumber(ctx, partNumber) + if err != nil || item == nil { + writeError(w, http.StatusNotFound, "not_found", "Item not found") + return + } + + var req AddItemProjectRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid_json", err.Error()) + return + } + + for _, code := range req.Projects { + if err := s.projects.AddItemToProjectByCode(ctx, item.ID, code); err != nil { + s.logger.Warn().Err(err).Str("project", code).Msg("failed to add project") + } + } + + // Return updated project list + projects, _ := s.projects.GetProjectsForItem(ctx, item.ID) + response := make([]ProjectResponse, len(projects)) + for i, p := range projects { + response[i] = projectToResponse(p) + } + + writeJSON(w, http.StatusOK, response) +} + +// HandleRemoveItemProject removes a project tag from an item. +func (s *Server) HandleRemoveItemProject(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + partNumber := chi.URLParam(r, "partNumber") + projectCode := chi.URLParam(r, "code") + + item, err := s.items.GetByPartNumber(ctx, partNumber) + if err != nil || item == nil { + writeError(w, http.StatusNotFound, "not_found", "Item not found") + return + } + + if err := s.projects.RemoveItemFromProjectByCode(ctx, item.ID, projectCode); err != nil { + s.logger.Error().Err(err).Msg("failed to remove project") + writeError(w, http.StatusInternalServerError, "remove_failed", err.Error()) + return + } + + w.WriteHeader(http.StatusNoContent) +} + +func projectToResponse(p *db.Project) ProjectResponse { + return ProjectResponse{ + ID: p.ID, + Code: p.Code, + Name: p.Name, + Description: p.Description, + CreatedAt: p.CreatedAt.Format("2006-01-02T15:04:05Z07:00"), + } +} diff --git a/internal/api/routes.go b/internal/api/routes.go index 04996cb..d9593ff 100644 --- a/internal/api/routes.go +++ b/internal/api/routes.go @@ -57,8 +57,15 @@ func NewRouter(server *Server, logger zerolog.Logger) http.Handler { }) }) - // Projects (distinct project codes from items) - r.Get("/projects", server.HandleListProjects) + // Projects + r.Route("/projects", func(r chi.Router) { + r.Get("/", server.HandleListProjects) + r.Post("/", server.HandleCreateProject) + r.Get("/{code}", server.HandleGetProject) + r.Put("/{code}", server.HandleUpdateProject) + r.Delete("/{code}", server.HandleDeleteProject) + r.Get("/{code}/items", server.HandleGetProjectItems) + }) // Items r.Route("/items", func(r chi.Router) { @@ -75,10 +82,18 @@ func NewRouter(server *Server, logger zerolog.Logger) http.Handler { r.Put("/", server.HandleUpdateItem) r.Delete("/", server.HandleDeleteItem) + // Item project tags + r.Get("/projects", server.HandleGetItemProjects) + r.Post("/projects", server.HandleAddItemProjects) + r.Delete("/projects/{code}", server.HandleRemoveItemProject) + // Revisions r.Get("/revisions", server.HandleListRevisions) r.Post("/revisions", server.HandleCreateRevision) + r.Get("/revisions/compare", server.HandleCompareRevisions) r.Get("/revisions/{revision}", server.HandleGetRevision) + r.Patch("/revisions/{revision}", server.HandleUpdateRevision) + r.Post("/revisions/{revision}/rollback", server.HandleRollbackRevision) // File upload/download r.Post("/file", server.HandleUploadFile) diff --git a/internal/api/templates/items.html b/internal/api/templates/items.html index 9dc1bf4..661693b 100644 --- a/internal/api/templates/items.html +++ b/internal/api/templates/items.html @@ -126,13 +126,10 @@
Search Tips
- 3DX15 - Search by project code + F01 - Search by category code
- A01 - Search by category -
-
- 3DX15-A01 - Project + category + F01-0001 - Full part number
0001 - Search by sequence @@ -202,36 +199,21 @@ > - +
-
- - - -
+ + +
+
+