Add revision control and project tagging migration
This commit is contained in:
@@ -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]
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}')">×</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();
|
||||
|
||||
@@ -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
299
internal/db/projects.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user