Add revision control and project tagging migration

This commit is contained in:
Forbes
2026-01-24 16:27:18 -06:00
parent c327baf36f
commit b396097715
11 changed files with 2941 additions and 229 deletions

View File

@@ -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]
}

View File

@@ -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"),
}
}

View File

@@ -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)

View File

@@ -126,13 +126,10 @@
<div class="search-help" id="search-help">
<div class="search-help-title">Search Tips</div>
<div class="search-help-item">
<code>3DX15</code> - Search by project code
<code>F01</code> - Search by category code
</div>
<div class="search-help-item">
<code>A01</code> - Search by category
</div>
<div class="search-help-item">
<code>3DX15-A01</code> - Project + category
<code>F01-0001</code> - Full part number
</div>
<div class="search-help-item">
<code>0001</code> - Search by sequence
@@ -202,36 +199,21 @@
>
<option value="">Start from scratch...</option>
<option value="machined-part">
Machined Part (M-category)
Machined Part (X-category)
</option>
<option value="printed-part">
3D Printed Part (F-category)
3D Printed Part (X-category)
</option>
<option value="fastener">Fastener (R-category)</option>
<option value="fastener">Fastener (F-category)</option>
<option value="electronics">
Electronics (E-category)
</option>
<option value="assembly">Assembly (A-category)</option>
<option value="purchased">
Purchased/COTS (S-category)
Purchased/COTS (P-category)
</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Project Code</label>
<select class="form-input" id="project" required>
<option value="">Select project...</option>
</select>
<input
type="text"
class="form-input"
id="project-new"
maxlength="5"
pattern="[A-Za-z0-9]{5}"
placeholder="Or enter new project code (5 chars)"
style="margin-top: 0.5rem"
/>
</div>
<div class="form-group">
<label class="form-label">Category</label>
<select class="form-input" id="category" required>
@@ -247,6 +229,19 @@
placeholder="Item description"
/>
</div>
<div class="form-group">
<label class="form-label">Project Tags (optional)</label>
<div class="project-tags-container" id="project-tags-container">
<select
class="form-input"
id="project-select"
onchange="addProjectTag()"
>
<option value="">Add project tag...</option>
</select>
<div class="selected-tags" id="selected-tags"></div>
</div>
</div>
<div class="form-actions">
<button
type="button"
@@ -414,13 +409,13 @@
<div class="import-instructions">
<p>Upload a CSV file to bulk import items. Required columns:</p>
<ul>
<li><code>project</code> - 5-character project code</li>
<li>
<code>category</code> - Category code (e.g., F01, A01)
</li>
</ul>
<p>
Optional columns: <code>description</code>,
<code>projects</code> (comma-separated project codes),
<code>part_number</code>, and any property columns.
</p>
<a href="/api/items/template.csv" class="template-link"
@@ -991,6 +986,162 @@
color: var(--ctp-subtext0);
font-style: italic;
}
/* Project Tags Styles */
.project-tags-container {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.selected-tags {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
min-height: 1.5rem;
}
.project-tag {
display: inline-flex;
align-items: center;
gap: 0.35rem;
background: var(--ctp-surface1);
border: 1px solid var(--ctp-surface2);
border-radius: 1rem;
padding: 0.25rem 0.5rem 0.25rem 0.75rem;
font-size: 0.85rem;
color: var(--ctp-text);
}
.project-tag .tag-remove {
background: transparent;
border: none;
color: var(--ctp-subtext0);
cursor: pointer;
padding: 0;
width: 1.25rem;
height: 1.25rem;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 1rem;
line-height: 1;
transition: all 0.2s;
}
.project-tag .tag-remove:hover {
background: var(--ctp-red);
color: var(--ctp-crust);
}
.item-projects {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
margin-top: 0.25rem;
}
.item-project-tag {
display: inline-block;
background: var(--ctp-surface1);
border: 1px solid var(--ctp-mauve);
border-radius: 0.75rem;
padding: 0.15rem 0.5rem;
font-size: 0.75rem;
color: var(--ctp-mauve);
font-weight: 500;
}
/* Revision Status Badges */
.status-badge {
display: inline-block;
padding: 0.2rem 0.5rem;
border-radius: 0.375rem;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
}
.status-draft {
background: var(--ctp-surface1);
color: var(--ctp-subtext0);
}
.status-review {
background: var(--ctp-yellow);
color: var(--ctp-crust);
}
.status-released {
background: var(--ctp-green);
color: var(--ctp-crust);
}
.status-obsolete {
background: var(--ctp-red);
color: var(--ctp-crust);
}
/* Revision Actions */
.revision-actions {
display: flex;
gap: 0.25rem;
}
.revision-actions .btn {
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
}
/* Revision Compare UI */
.compare-controls {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 1rem;
padding: 0.75rem;
background: var(--ctp-surface0);
border-radius: 0.5rem;
}
.compare-controls select {
padding: 0.4rem 0.75rem;
background: var(--ctp-base);
border: 1px solid var(--ctp-surface2);
border-radius: 0.375rem;
color: var(--ctp-text);
font-size: 0.85rem;
}
.compare-result {
margin-top: 1rem;
padding: 1rem;
background: var(--ctp-surface0);
border-radius: 0.5rem;
}
.compare-result h4 {
margin: 0 0 0.75rem 0;
color: var(--ctp-subtext1);
font-size: 0.9rem;
}
.diff-section {
margin-bottom: 1rem;
}
.diff-section h5 {
margin: 0 0 0.5rem 0;
font-size: 0.85rem;
}
.diff-added {
color: var(--ctp-green);
}
.diff-removed {
color: var(--ctp-red);
}
.diff-changed {
color: var(--ctp-yellow);
}
.diff-item {
font-family: "JetBrains Mono", monospace;
font-size: 0.8rem;
padding: 0.25rem 0.5rem;
background: var(--ctp-base);
border-radius: 0.25rem;
margin-bottom: 0.25rem;
}
/* Status Select Dropdown */
.status-select {
padding: 0.2rem 0.4rem;
font-size: 0.75rem;
background: var(--ctp-surface1);
border: 1px solid var(--ctp-surface2);
border-radius: 0.375rem;
color: var(--ctp-text);
cursor: pointer;
}
</style>
<script>
let currentPage = 1;
@@ -1022,14 +1173,17 @@
// Item templates for quick creation
const itemTemplates = {
"machined-part": { category: "M09", descPrefix: "MACHINED " },
"printed-part": { category: "F10", descPrefix: "3DP " },
fastener: { category: "R01", descPrefix: "" },
"machined-part": { category: "X01", descPrefix: "MACHINED " },
"printed-part": { category: "X03", descPrefix: "3DP " },
fastener: { category: "F01", descPrefix: "" },
electronics: { category: "E01", descPrefix: "" },
assembly: { category: "A01", descPrefix: "" },
purchased: { category: "S05", descPrefix: "COTS " },
purchased: { category: "P01", descPrefix: "COTS " },
};
// Selected project tags for new item creation
let selectedProjectTags = [];
// Load schema for create form
async function loadSchema() {
try {
@@ -1061,7 +1215,9 @@
async function loadProjectCodes() {
try {
const response = await fetch("/api/projects");
projectCodes = await response.json();
const projects = await response.json();
// Extract codes from project objects
projectCodes = projects.map((p) => p.code || p);
// Populate project filter dropdown
const projectFilter = document.getElementById("project-filter");
@@ -1072,19 +1228,56 @@
projectFilter.appendChild(option);
});
// Populate create form project dropdown
const projectSelect = document.getElementById("project");
projectCodes.forEach((code) => {
const option = document.createElement("option");
option.value = code;
option.textContent = code;
projectSelect.appendChild(option);
});
// Populate create form project tag dropdown
const projectSelect = document.getElementById("project-select");
if (projectSelect) {
projectCodes.forEach((code) => {
const option = document.createElement("option");
option.value = code;
option.textContent = code;
projectSelect.appendChild(option);
});
}
} catch (error) {
console.error("Failed to load project codes:", error);
}
}
// Project tag management for create form
function addProjectTag() {
const select = document.getElementById("project-select");
const code = select.value;
if (!code || selectedProjectTags.includes(code)) {
select.value = "";
return;
}
selectedProjectTags.push(code);
renderSelectedTags();
select.value = "";
}
function removeProjectTag(code) {
selectedProjectTags = selectedProjectTags.filter((c) => c !== code);
renderSelectedTags();
}
function renderSelectedTags() {
const container = document.getElementById("selected-tags");
if (!container) return;
container.innerHTML = selectedProjectTags
.map(
(code) => `
<span class="project-tag">
${code}
<button type="button" class="tag-remove" onclick="removeProjectTag('${code}')">&times;</button>
</span>
`,
)
.join("");
}
// Apply template to create form
function applyTemplate() {
const templateId = document.getElementById("template").value;
@@ -1230,36 +1423,31 @@
// Create Modal functions
function openCreateModal() {
document.getElementById("create-modal").classList.add("active");
selectedProjectTags = [];
renderSelectedTags();
}
function closeCreateModal() {
document.getElementById("create-modal").classList.remove("active");
document.getElementById("create-form").reset();
selectedProjectTags = [];
renderSelectedTags();
}
async function createItem(event) {
event.preventDefault();
// Use new project code if provided, otherwise use dropdown selection
const projectSelect = document.getElementById("project").value;
const projectNew = document
.getElementById("project-new")
.value.trim()
.toUpperCase();
const project = projectNew || projectSelect;
if (!project) {
alert("Please select or enter a project code");
return;
}
const data = {
schema: "kindred-rd",
project: project,
category: document.getElementById("category").value,
description: document.getElementById("description").value,
};
// Add project tags if any selected
if (selectedProjectTags.length > 0) {
data.projects = selectedProjectTags;
}
try {
const response = await fetch("/api/items", {
method: "POST",
@@ -1274,7 +1462,11 @@
}
const item = await response.json();
alert(`Created: ${item.part_number}`);
let msg = `Created: ${item.part_number}`;
if (selectedProjectTags.length > 0) {
msg += `\nTagged with: ${selectedProjectTags.join(", ")}`;
}
alert(msg);
closeCreateModal();
loadItems();
} catch (error) {
@@ -1424,9 +1616,27 @@
fetch(`/api/items/${partNumber}/revisions`),
]);
if (!itemRes.ok) {
const error = await itemRes.json();
throw new Error(
error.message || error.error || "Failed to load item",
);
}
if (!revsRes.ok) {
const error = await revsRes.json();
throw new Error(
error.message || error.error || "Failed to load revisions",
);
}
const item = await itemRes.json();
const revisions = await revsRes.json();
// Ensure revisions is an array
if (!Array.isArray(revisions)) {
throw new Error("Invalid revisions response");
}
currentDetailItem = item;
currentDetailRevisions = revisions;
@@ -1474,6 +1684,28 @@
`;
}
// Fetch project tags for this item
let projectTagsHtml =
'<span class="item-projects"><em style="color: var(--ctp-subtext0);">None</em></span>';
try {
const projectsRes = await fetch(
`/api/items/${partNumber}/projects`,
);
if (projectsRes.ok) {
const itemProjects = await projectsRes.json();
if (itemProjects && itemProjects.length > 0) {
projectTagsHtml = `<span class="item-projects">${itemProjects
.map(
(p) =>
`<span class="item-project-tag">${p.code || p}</span>`,
)
.join("")}</span>`;
}
}
} catch (e) {
console.warn("Failed to load project tags:", e);
}
// Info tab
document.getElementById("tab-info").innerHTML = `
<div style="margin-bottom: 1.5rem;">
@@ -1481,6 +1713,7 @@
<p><strong>Part Number:</strong> <span class="part-number-container"><span class="part-number">${item.part_number}</span><button class="copy-btn" onclick="copyPartNumber('${item.part_number}', this)" title="Copy part number">${icons.clipboard}</button></span></p>
<p><strong>Type:</strong> <span class="item-type item-type-${item.item_type}">${item.item_type}</span></p>
<p><strong>Description:</strong> ${item.description || "-"}</p>
<p><strong>Projects:</strong> ${projectTagsHtml}</p>
<p><strong>Current Revision:</strong> ${item.current_revision}</p>
<p><strong>Created:</strong> ${formatDate(item.created_at)}</p>
<p><strong>Updated:</strong> ${formatDate(item.updated_at)}</p>
@@ -1491,27 +1724,55 @@
// Properties tab
renderPropertiesTab(item.properties || {}, item.current_revision);
// Revisions tab
// Revisions tab with compare and rollback
const revisionOptions = revisions
.map(
(r) =>
`<option value="${r.revision_number}">Rev ${r.revision_number}</option>`,
)
.join("");
document.getElementById("tab-revisions").innerHTML = `
<div class="compare-controls">
<span>Compare:</span>
<select id="compare-from">${revisionOptions}</select>
<span>to</span>
<select id="compare-to">${revisionOptions}</select>
<button class="btn btn-secondary" onclick="compareRevisions('${partNumber}')">Compare</button>
</div>
<div id="compare-result"></div>
<div class="table-container">
<table>
<thead>
<tr>
<th>Rev</th>
<th>Status</th>
<th>Date</th>
<th>File</th>
<th>Comment</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
${revisions
.map(
(rev) => `
(rev, idx) => `
<tr>
<td>${rev.revision_number}</td>
<td>
<select class="status-select" onchange="updateRevisionStatus('${partNumber}', ${rev.revision_number}, this.value)">
<option value="draft" ${rev.status === "draft" ? "selected" : ""}>Draft</option>
<option value="review" ${rev.status === "review" ? "selected" : ""}>Review</option>
<option value="released" ${rev.status === "released" ? "selected" : ""}>Released</option>
<option value="obsolete" ${rev.status === "obsolete" ? "selected" : ""}>Obsolete</option>
</select>
</td>
<td>${formatDate(rev.created_at)}</td>
<td>${rev.file_key ? `<button class="copy-btn" onclick="downloadFile('${partNumber}', ${rev.revision_number})" title="Download">${icons.download}</button>` : "-"}</td>
<td>${rev.comment || "-"}</td>
<td class="revision-actions">
${idx > 0 ? `<button class="btn btn-secondary" onclick="rollbackToRevision('${partNumber}', ${rev.revision_number})" title="Rollback to this revision">Rollback</button>` : '<span style="color: var(--ctp-subtext0); font-size: 0.75rem;">Current</span>'}
</td>
</tr>
`,
)
@@ -1520,6 +1781,14 @@
</table>
</div>
`;
// Set default compare selection (latest vs previous)
if (revisions.length >= 2) {
document.getElementById("compare-from").value =
revisions[1].revision_number;
document.getElementById("compare-to").value =
revisions[0].revision_number;
}
} catch (error) {
document.getElementById("tab-info").innerHTML =
`<p>Error loading item: ${error.message}</p>`;
@@ -1984,6 +2253,174 @@
}
}
// Revision Control Functions
// Compare two revisions and display the diff
async function compareRevisions(partNumber) {
const fromRev = document.getElementById("compare-from").value;
const toRev = document.getElementById("compare-to").value;
const resultDiv = document.getElementById("compare-result");
if (fromRev === toRev) {
resultDiv.innerHTML =
'<p style="color: var(--ctp-subtext0);">Select two different revisions to compare.</p>';
return;
}
resultDiv.innerHTML =
'<div class="loading"><div class="spinner"></div></div>';
try {
const response = await fetch(
`/api/items/${partNumber}/revisions/compare?from=${fromRev}&to=${toRev}`,
);
if (!response.ok) {
const error = await response.json();
resultDiv.innerHTML = `<p style="color: var(--ctp-red);">Error: ${error.message || error.error}</p>`;
return;
}
const diff = await response.json();
let html = `<div class="compare-result">
<h4>Changes from Rev ${diff.from_revision} to Rev ${diff.to_revision}</h4>`;
// Status change
if (diff.from_status !== diff.to_status) {
html += `<div class="diff-section">
<h5 class="diff-changed">Status Changed</h5>
<div class="diff-item">${diff.from_status}${diff.to_status}</div>
</div>`;
}
// File change
if (diff.file_changed) {
const sizeChange = diff.file_size_diff
? diff.file_size_diff > 0
? `+${formatFileSize(diff.file_size_diff)}`
: formatFileSize(diff.file_size_diff)
: "";
html += `<div class="diff-section">
<h5 class="diff-changed">File Changed</h5>
<div class="diff-item">File was modified ${sizeChange ? `(${sizeChange})` : ""}</div>
</div>`;
}
// Added properties
if (diff.added && Object.keys(diff.added).length > 0) {
html += `<div class="diff-section">
<h5 class="diff-added">+ Added Properties</h5>`;
for (const [key, value] of Object.entries(diff.added)) {
html += `<div class="diff-item diff-added">+ ${escapeHtml(key)}: ${escapeHtml(JSON.stringify(value))}</div>`;
}
html += `</div>`;
}
// Removed properties
if (diff.removed && Object.keys(diff.removed).length > 0) {
html += `<div class="diff-section">
<h5 class="diff-removed">- Removed Properties</h5>`;
for (const [key, value] of Object.entries(diff.removed)) {
html += `<div class="diff-item diff-removed">- ${escapeHtml(key)}: ${escapeHtml(JSON.stringify(value))}</div>`;
}
html += `</div>`;
}
// Changed properties
if (diff.changed && Object.keys(diff.changed).length > 0) {
html += `<div class="diff-section">
<h5 class="diff-changed">~ Changed Properties</h5>`;
for (const [key, change] of Object.entries(diff.changed)) {
html += `<div class="diff-item diff-changed">~ ${escapeHtml(key)}: ${escapeHtml(JSON.stringify(change.old_value))}${escapeHtml(JSON.stringify(change.new_value))}</div>`;
}
html += `</div>`;
}
// No changes
if (
(!diff.added || Object.keys(diff.added).length === 0) &&
(!diff.removed || Object.keys(diff.removed).length === 0) &&
(!diff.changed || Object.keys(diff.changed).length === 0) &&
!diff.file_changed &&
diff.from_status === diff.to_status
) {
html += `<p style="color: var(--ctp-subtext0);">No property changes between these revisions.</p>`;
}
html += `</div>`;
resultDiv.innerHTML = html;
} catch (error) {
resultDiv.innerHTML = `<p style="color: var(--ctp-red);">Error: ${error.message}</p>`;
}
}
// Update revision status
async function updateRevisionStatus(partNumber, revision, status) {
try {
const response = await fetch(
`/api/items/${partNumber}/revisions/${revision}`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status }),
},
);
if (!response.ok) {
const error = await response.json();
alert(`Error: ${error.message || error.error}`);
// Reload to restore the original status
showItemDetail(partNumber);
return;
}
// Brief visual feedback - the select already shows the new value
} catch (error) {
alert(`Error: ${error.message}`);
showItemDetail(partNumber);
}
}
// Rollback to a previous revision
async function rollbackToRevision(partNumber, revision) {
const confirmed = confirm(
`Rollback to Revision ${revision}?\n\nThis will create a NEW revision with the properties and file from Rev ${revision}. The revision history is preserved.`,
);
if (!confirmed) return;
const comment = prompt(
"Rollback comment (optional):",
`Rollback to revision ${revision}`,
);
if (comment === null) return; // User cancelled
try {
const response = await fetch(
`/api/items/${partNumber}/revisions/${revision}/rollback`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
comment: comment || `Rollback to revision ${revision}`,
}),
},
);
if (!response.ok) {
const error = await response.json();
alert(`Error: ${error.message || error.error}`);
return;
}
const result = await response.json();
alert(`Created revision ${result.revision_number} from rollback`);
showItemDetail(partNumber);
loadItems();
} catch (error) {
alert(`Error: ${error.message}`);
}
}
// Initialize
loadSchema();
loadProjectCodes();

View File

@@ -38,6 +38,35 @@ type Revision struct {
CreatedAt time.Time
CreatedBy *string
Comment *string
Status string // draft, review, released, obsolete
Labels []string // arbitrary tags
}
// RevisionStatus constants
const (
RevisionStatusDraft = "draft"
RevisionStatusReview = "review"
RevisionStatusReleased = "released"
RevisionStatusObsolete = "obsolete"
)
// PropertyChange represents a change in a property value between revisions.
type PropertyChange struct {
OldValue any `json:"old_value"`
NewValue any `json:"new_value"`
}
// RevisionDiff represents the differences between two revisions.
type RevisionDiff 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]PropertyChange `json:"changed,omitempty"`
}
// ItemRepository provides item database operations.
@@ -131,35 +160,53 @@ func (r *ItemRepository) GetByID(ctx context.Context, id string) (*Item, error)
// List retrieves items with optional filtering.
func (r *ItemRepository) List(ctx context.Context, opts ListOptions) ([]*Item, error) {
query := `
SELECT id, part_number, schema_id, item_type, description,
created_at, updated_at, archived_at, current_revision
FROM items
WHERE archived_at IS NULL
`
// Build query - use JOIN if filtering by project
var query string
args := []any{}
argNum := 1
if opts.Project != "" {
// Filter by project via many-to-many relationship
query = `
SELECT DISTINCT i.id, i.part_number, i.schema_id, i.item_type, i.description,
i.created_at, i.updated_at, i.archived_at, i.current_revision
FROM items i
JOIN item_projects ip ON ip.item_id = i.id
JOIN projects p ON p.id = ip.project_id
WHERE i.archived_at IS NULL AND p.code = $1
`
args = append(args, opts.Project)
argNum++
} else {
query = `
SELECT id, part_number, schema_id, item_type, description,
created_at, updated_at, archived_at, current_revision
FROM items
WHERE archived_at IS NULL
`
}
if opts.ItemType != "" {
query += fmt.Sprintf(" AND item_type = $%d", argNum)
args = append(args, opts.ItemType)
argNum++
}
if opts.Project != "" {
// Filter by project code (first 5 characters of part number)
query += fmt.Sprintf(" AND part_number LIKE $%d", argNum)
args = append(args, opts.Project+"%")
argNum++
}
if opts.Search != "" {
query += fmt.Sprintf(" AND (part_number ILIKE $%d OR description ILIKE $%d)", argNum, argNum)
if opts.Project != "" {
query += fmt.Sprintf(" AND (i.part_number ILIKE $%d OR i.description ILIKE $%d)", argNum, argNum)
} else {
query += fmt.Sprintf(" AND (part_number ILIKE $%d OR description ILIKE $%d)", argNum, argNum)
}
args = append(args, "%"+opts.Search+"%")
argNum++
}
query += " ORDER BY part_number"
if opts.Project != "" {
query += " ORDER BY i.part_number"
} else {
query += " ORDER BY part_number"
}
if opts.Limit > 0 {
query += fmt.Sprintf(" LIMIT $%d", argNum)
@@ -194,13 +241,11 @@ func (r *ItemRepository) List(ctx context.Context, opts ListOptions) ([]*Item, e
return items, nil
}
// ListProjects returns distinct project codes from all items.
// ListProjects returns all project codes from the projects table.
// Deprecated: Use ProjectRepository.List() instead for full project details.
func (r *ItemRepository) ListProjects(ctx context.Context) ([]string, error) {
rows, err := r.db.pool.Query(ctx, `
SELECT DISTINCT SUBSTRING(part_number FROM 1 FOR 5) as project_code
FROM items
WHERE archived_at IS NULL
ORDER BY project_code
SELECT code FROM projects ORDER BY code
`)
if err != nil {
return nil, fmt.Errorf("querying projects: %w", err)
@@ -255,13 +300,37 @@ func (r *ItemRepository) CreateRevision(ctx context.Context, rev *Revision) erro
// GetRevisions retrieves all revisions for an item.
func (r *ItemRepository) GetRevisions(ctx context.Context, itemID string) ([]*Revision, error) {
rows, err := r.db.pool.Query(ctx, `
SELECT id, item_id, revision_number, properties, file_key, file_version,
file_checksum, file_size, thumbnail_key, created_at, created_by, comment
FROM revisions
WHERE item_id = $1
ORDER BY revision_number DESC
`, itemID)
// Check if status column exists (migration 007 applied)
var hasStatusColumn bool
err := r.db.pool.QueryRow(ctx, `
SELECT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'revisions' AND column_name = 'status'
)
`).Scan(&hasStatusColumn)
if err != nil {
return nil, fmt.Errorf("checking schema: %w", err)
}
var rows pgx.Rows
if hasStatusColumn {
rows, err = r.db.pool.Query(ctx, `
SELECT id, item_id, revision_number, properties, file_key, file_version,
file_checksum, file_size, thumbnail_key, created_at, created_by, comment,
COALESCE(status, 'draft') as status, COALESCE(labels, ARRAY[]::TEXT[]) as labels
FROM revisions
WHERE item_id = $1
ORDER BY revision_number DESC
`, itemID)
} else {
rows, err = r.db.pool.Query(ctx, `
SELECT id, item_id, revision_number, properties, file_key, file_version,
file_checksum, file_size, thumbnail_key, created_at, created_by, comment
FROM revisions
WHERE item_id = $1
ORDER BY revision_number DESC
`, itemID)
}
if err != nil {
return nil, fmt.Errorf("querying revisions: %w", err)
}
@@ -271,10 +340,20 @@ func (r *ItemRepository) GetRevisions(ctx context.Context, itemID string) ([]*Re
for rows.Next() {
rev := &Revision{}
var propsJSON []byte
err := rows.Scan(
&rev.ID, &rev.ItemID, &rev.RevisionNumber, &propsJSON, &rev.FileKey, &rev.FileVersion,
&rev.FileChecksum, &rev.FileSize, &rev.ThumbnailKey, &rev.CreatedAt, &rev.CreatedBy, &rev.Comment,
)
if hasStatusColumn {
err = rows.Scan(
&rev.ID, &rev.ItemID, &rev.RevisionNumber, &propsJSON, &rev.FileKey, &rev.FileVersion,
&rev.FileChecksum, &rev.FileSize, &rev.ThumbnailKey, &rev.CreatedAt, &rev.CreatedBy, &rev.Comment,
&rev.Status, &rev.Labels,
)
} else {
err = rows.Scan(
&rev.ID, &rev.ItemID, &rev.RevisionNumber, &propsJSON, &rev.FileKey, &rev.FileVersion,
&rev.FileChecksum, &rev.FileSize, &rev.ThumbnailKey, &rev.CreatedAt, &rev.CreatedBy, &rev.Comment,
)
rev.Status = "draft"
rev.Labels = []string{}
}
if err != nil {
return nil, fmt.Errorf("scanning revision: %w", err)
}
@@ -287,6 +366,228 @@ func (r *ItemRepository) GetRevisions(ctx context.Context, itemID string) ([]*Re
return revisions, nil
}
// GetRevision retrieves a specific revision by item ID and revision number.
func (r *ItemRepository) GetRevision(ctx context.Context, itemID string, revisionNumber int) (*Revision, error) {
rev := &Revision{}
var propsJSON []byte
err := r.db.pool.QueryRow(ctx, `
SELECT id, item_id, revision_number, properties, file_key, file_version,
file_checksum, file_size, thumbnail_key, created_at, created_by, comment,
COALESCE(status, 'draft') as status, COALESCE(labels, '{}') as labels
FROM revisions
WHERE item_id = $1 AND revision_number = $2
`, itemID, revisionNumber).Scan(
&rev.ID, &rev.ItemID, &rev.RevisionNumber, &propsJSON, &rev.FileKey, &rev.FileVersion,
&rev.FileChecksum, &rev.FileSize, &rev.ThumbnailKey, &rev.CreatedAt, &rev.CreatedBy, &rev.Comment,
&rev.Status, &rev.Labels,
)
if err == pgx.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("querying revision: %w", err)
}
if err := json.Unmarshal(propsJSON, &rev.Properties); err != nil {
return nil, fmt.Errorf("unmarshaling properties: %w", err)
}
return rev, nil
}
// UpdateRevisionStatus updates the status and/or labels of a revision.
func (r *ItemRepository) UpdateRevisionStatus(ctx context.Context, itemID string, revisionNumber int, status *string, labels []string) error {
if status == nil && labels == nil {
return nil // Nothing to update
}
if status != nil {
// Validate status
switch *status {
case RevisionStatusDraft, RevisionStatusReview, RevisionStatusReleased, RevisionStatusObsolete:
// Valid
default:
return fmt.Errorf("invalid status: %s", *status)
}
}
// Build dynamic update query
query := "UPDATE revisions SET "
args := []any{}
argNum := 1
updates := []string{}
if status != nil {
updates = append(updates, fmt.Sprintf("status = $%d", argNum))
args = append(args, *status)
argNum++
}
if labels != nil {
updates = append(updates, fmt.Sprintf("labels = $%d", argNum))
args = append(args, labels)
argNum++
}
query += updates[0]
for i := 1; i < len(updates); i++ {
query += ", " + updates[i]
}
query += fmt.Sprintf(" WHERE item_id = $%d AND revision_number = $%d", argNum, argNum+1)
args = append(args, itemID, revisionNumber)
result, err := r.db.pool.Exec(ctx, query, args...)
if err != nil {
return fmt.Errorf("updating revision: %w", err)
}
if result.RowsAffected() == 0 {
return fmt.Errorf("revision not found")
}
return nil
}
// CompareRevisions computes the differences between two revisions.
func (r *ItemRepository) CompareRevisions(ctx context.Context, itemID string, fromRev, toRev int) (*RevisionDiff, error) {
// Get both revisions
from, err := r.GetRevision(ctx, itemID, fromRev)
if err != nil {
return nil, fmt.Errorf("getting from revision: %w", err)
}
if from == nil {
return nil, fmt.Errorf("revision %d not found", fromRev)
}
to, err := r.GetRevision(ctx, itemID, toRev)
if err != nil {
return nil, fmt.Errorf("getting to revision: %w", err)
}
if to == nil {
return nil, fmt.Errorf("revision %d not found", toRev)
}
diff := &RevisionDiff{
FromRevision: fromRev,
ToRevision: toRev,
FromStatus: from.Status,
ToStatus: to.Status,
Added: make(map[string]any),
Removed: make(map[string]any),
Changed: make(map[string]PropertyChange),
}
// Check file changes
fromChecksum := ""
toChecksum := ""
if from.FileChecksum != nil {
fromChecksum = *from.FileChecksum
}
if to.FileChecksum != nil {
toChecksum = *to.FileChecksum
}
diff.FileChanged = fromChecksum != toChecksum
// Calculate file size difference
if from.FileSize != nil && to.FileSize != nil {
sizeDiff := *to.FileSize - *from.FileSize
diff.FileSizeDiff = &sizeDiff
}
// Compare properties
// Find added and changed properties
for key, toVal := range to.Properties {
fromVal, exists := from.Properties[key]
if !exists {
diff.Added[key] = toVal
} else if !equalValues(fromVal, toVal) {
diff.Changed[key] = PropertyChange{
OldValue: fromVal,
NewValue: toVal,
}
}
}
// Find removed properties
for key, fromVal := range from.Properties {
if _, exists := to.Properties[key]; !exists {
diff.Removed[key] = fromVal
}
}
// Clean up empty maps for cleaner JSON
if len(diff.Added) == 0 {
diff.Added = nil
}
if len(diff.Removed) == 0 {
diff.Removed = nil
}
if len(diff.Changed) == 0 {
diff.Changed = nil
}
return diff, nil
}
// equalValues compares two property values for equality.
func equalValues(a, b any) bool {
// Use JSON encoding for deep comparison
aJSON, err1 := json.Marshal(a)
bJSON, err2 := json.Marshal(b)
if err1 != nil || err2 != nil {
return false
}
return string(aJSON) == string(bJSON)
}
// CreateRevisionFromExisting creates a new revision by copying from an existing one (rollback).
func (r *ItemRepository) CreateRevisionFromExisting(ctx context.Context, itemID string, sourceRevNumber int, comment string, createdBy *string) (*Revision, error) {
// Get the source revision
source, err := r.GetRevision(ctx, itemID, sourceRevNumber)
if err != nil {
return nil, fmt.Errorf("getting source revision: %w", err)
}
if source == nil {
return nil, fmt.Errorf("source revision %d not found", sourceRevNumber)
}
// Create new revision with copied properties (and optionally file reference)
newRev := &Revision{
ItemID: itemID,
Properties: source.Properties,
FileKey: source.FileKey,
FileVersion: source.FileVersion,
FileChecksum: source.FileChecksum,
FileSize: source.FileSize,
ThumbnailKey: source.ThumbnailKey,
CreatedBy: createdBy,
Comment: &comment,
}
// Insert the new revision
propsJSON, err := json.Marshal(newRev.Properties)
if err != nil {
return nil, fmt.Errorf("marshaling properties: %w", err)
}
err = r.db.pool.QueryRow(ctx, `
INSERT INTO revisions (
item_id, revision_number, properties, file_key, file_version,
file_checksum, file_size, thumbnail_key, created_by, comment, status
)
SELECT $1, current_revision + 1, $2, $3, $4, $5, $6, $7, $8, $9, 'draft'
FROM items WHERE id = $1
RETURNING id, revision_number, created_at
`, newRev.ItemID, propsJSON, newRev.FileKey, newRev.FileVersion,
newRev.FileChecksum, newRev.FileSize, newRev.ThumbnailKey, newRev.CreatedBy, newRev.Comment,
).Scan(&newRev.ID, &newRev.RevisionNumber, &newRev.CreatedAt)
if err != nil {
return nil, fmt.Errorf("inserting revision: %w", err)
}
newRev.Status = RevisionStatusDraft
return newRev, nil
}
// Archive soft-deletes an item.
func (r *ItemRepository) Archive(ctx context.Context, id string) error {
_, err := r.db.pool.Exec(ctx, `

299
internal/db/projects.go Normal file
View File

@@ -0,0 +1,299 @@
package db
import (
"context"
"time"
"github.com/jackc/pgx/v5"
)
// Project represents a project in the database.
type Project struct {
ID string
Code string
Name string
Description string
CreatedAt time.Time
}
// ProjectRepository provides project database operations.
type ProjectRepository struct {
db *DB
}
// NewProjectRepository creates a new project repository.
func NewProjectRepository(db *DB) *ProjectRepository {
return &ProjectRepository{db: db}
}
// List returns all projects.
func (r *ProjectRepository) List(ctx context.Context) ([]*Project, error) {
rows, err := r.db.pool.Query(ctx, `
SELECT id, code, name, description, created_at
FROM projects
ORDER BY code
`)
if err != nil {
return nil, err
}
defer rows.Close()
var projects []*Project
for rows.Next() {
p := &Project{}
var name, desc *string
if err := rows.Scan(&p.ID, &p.Code, &name, &desc, &p.CreatedAt); err != nil {
return nil, err
}
if name != nil {
p.Name = *name
}
if desc != nil {
p.Description = *desc
}
projects = append(projects, p)
}
return projects, rows.Err()
}
// GetByCode returns a project by its code.
func (r *ProjectRepository) GetByCode(ctx context.Context, code string) (*Project, error) {
p := &Project{}
var name, desc *string
err := r.db.pool.QueryRow(ctx, `
SELECT id, code, name, description, created_at
FROM projects
WHERE code = $1
`, code).Scan(&p.ID, &p.Code, &name, &desc, &p.CreatedAt)
if err == pgx.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
if name != nil {
p.Name = *name
}
if desc != nil {
p.Description = *desc
}
return p, nil
}
// GetByID returns a project by its ID.
func (r *ProjectRepository) GetByID(ctx context.Context, id string) (*Project, error) {
p := &Project{}
var name, desc *string
err := r.db.pool.QueryRow(ctx, `
SELECT id, code, name, description, created_at
FROM projects
WHERE id = $1
`, id).Scan(&p.ID, &p.Code, &name, &desc, &p.CreatedAt)
if err == pgx.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
if name != nil {
p.Name = *name
}
if desc != nil {
p.Description = *desc
}
return p, nil
}
// Create inserts a new project.
func (r *ProjectRepository) Create(ctx context.Context, p *Project) error {
return r.db.pool.QueryRow(ctx, `
INSERT INTO projects (code, name, description)
VALUES ($1, $2, $3)
RETURNING id, created_at
`, p.Code, nullIfEmpty(p.Name), nullIfEmpty(p.Description)).Scan(&p.ID, &p.CreatedAt)
}
// Update updates a project's name and description.
func (r *ProjectRepository) Update(ctx context.Context, code string, name, description string) error {
_, err := r.db.pool.Exec(ctx, `
UPDATE projects
SET name = $2, description = $3
WHERE code = $1
`, code, nullIfEmpty(name), nullIfEmpty(description))
return err
}
// Delete removes a project (will cascade to item_projects).
func (r *ProjectRepository) Delete(ctx context.Context, code string) error {
_, err := r.db.pool.Exec(ctx, `DELETE FROM projects WHERE code = $1`, code)
return err
}
// AddItemToProject associates an item with a project.
func (r *ProjectRepository) AddItemToProject(ctx context.Context, itemID, projectID string) error {
_, err := r.db.pool.Exec(ctx, `
INSERT INTO item_projects (item_id, project_id)
VALUES ($1, $2)
ON CONFLICT (item_id, project_id) DO NOTHING
`, itemID, projectID)
return err
}
// AddItemToProjectByCode associates an item with a project by code.
func (r *ProjectRepository) AddItemToProjectByCode(ctx context.Context, itemID, projectCode string) error {
_, err := r.db.pool.Exec(ctx, `
INSERT INTO item_projects (item_id, project_id)
SELECT $1, id FROM projects WHERE code = $2
ON CONFLICT (item_id, project_id) DO NOTHING
`, itemID, projectCode)
return err
}
// RemoveItemFromProject removes an item's association with a project.
func (r *ProjectRepository) RemoveItemFromProject(ctx context.Context, itemID, projectID string) error {
_, err := r.db.pool.Exec(ctx, `
DELETE FROM item_projects
WHERE item_id = $1 AND project_id = $2
`, itemID, projectID)
return err
}
// RemoveItemFromProjectByCode removes an item's association with a project by code.
func (r *ProjectRepository) RemoveItemFromProjectByCode(ctx context.Context, itemID, projectCode string) error {
_, err := r.db.pool.Exec(ctx, `
DELETE FROM item_projects
WHERE item_id = $1 AND project_id = (SELECT id FROM projects WHERE code = $2)
`, itemID, projectCode)
return err
}
// GetProjectsForItem returns all projects associated with an item.
func (r *ProjectRepository) GetProjectsForItem(ctx context.Context, itemID string) ([]*Project, error) {
rows, err := r.db.pool.Query(ctx, `
SELECT p.id, p.code, p.name, p.description, p.created_at
FROM projects p
JOIN item_projects ip ON ip.project_id = p.id
WHERE ip.item_id = $1
ORDER BY p.code
`, itemID)
if err != nil {
return nil, err
}
defer rows.Close()
var projects []*Project
for rows.Next() {
p := &Project{}
var name, desc *string
if err := rows.Scan(&p.ID, &p.Code, &name, &desc, &p.CreatedAt); err != nil {
return nil, err
}
if name != nil {
p.Name = *name
}
if desc != nil {
p.Description = *desc
}
projects = append(projects, p)
}
return projects, rows.Err()
}
// GetProjectCodesForItem returns project codes for an item (convenience method).
func (r *ProjectRepository) GetProjectCodesForItem(ctx context.Context, itemID string) ([]string, error) {
rows, err := r.db.pool.Query(ctx, `
SELECT p.code
FROM projects p
JOIN item_projects ip ON ip.project_id = p.id
WHERE ip.item_id = $1
ORDER BY p.code
`, itemID)
if err != nil {
return nil, err
}
defer rows.Close()
var codes []string
for rows.Next() {
var code string
if err := rows.Scan(&code); err != nil {
return nil, err
}
codes = append(codes, code)
}
return codes, rows.Err()
}
// GetItemsForProject returns all items associated with a project.
func (r *ProjectRepository) GetItemsForProject(ctx context.Context, projectID string) ([]*Item, error) {
rows, err := r.db.pool.Query(ctx, `
SELECT i.id, i.part_number, i.schema_id, i.item_type, i.description,
i.created_at, i.updated_at, i.archived_at, i.current_revision,
i.cad_synced_at, i.cad_file_path
FROM items i
JOIN item_projects ip ON ip.item_id = i.id
WHERE ip.project_id = $1 AND i.archived_at IS NULL
ORDER BY i.part_number
`, projectID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []*Item
for rows.Next() {
item := &Item{}
if err := rows.Scan(
&item.ID, &item.PartNumber, &item.SchemaID, &item.ItemType, &item.Description,
&item.CreatedAt, &item.UpdatedAt, &item.ArchivedAt, &item.CurrentRevision,
&item.CADSyncedAt, &item.CADFilePath,
); err != nil {
return nil, err
}
items = append(items, item)
}
return items, rows.Err()
}
// SetItemProjects replaces all project associations for an item.
func (r *ProjectRepository) SetItemProjects(ctx context.Context, itemID string, projectCodes []string) error {
return r.db.Tx(ctx, func(tx pgx.Tx) error {
// Remove existing associations
_, err := tx.Exec(ctx, `DELETE FROM item_projects WHERE item_id = $1`, itemID)
if err != nil {
return err
}
// Add new associations
for _, code := range projectCodes {
_, err := tx.Exec(ctx, `
INSERT INTO item_projects (item_id, project_id)
SELECT $1, id FROM projects WHERE code = $2
ON CONFLICT (item_id, project_id) DO NOTHING
`, itemID, code)
if err != nil {
return err
}
}
return nil
})
}
// helper function
func nullIfEmpty(s string) *string {
if s == "" {
return nil
}
return &s
}