Files
silo/internal/api/handlers.go
Forbes 50923cf56d feat: production release with React SPA, file attachments, and deploy tooling
Backend:
- Add file_handlers.go: presigned upload/download for item attachments
- Add item_files.go: item file and thumbnail DB operations
- Add migration 011: item_files table and thumbnail_key column
- Update items/projects/relationships DB with extended field support
- Update routes: React SPA serving from web/dist, file upload endpoints
- Update auth handlers and middleware for cookie + bearer token auth
- Remove Go HTML templates (replaced by React SPA)
- Update storage client for presigned URL generation

Frontend:
- Add TagInput component for tag/keyword entry
- Add SVG assets for Silo branding and UI icons
- Update API client and types for file uploads, auth, extended fields
- Update AuthContext for session-based auth flow
- Update LoginPage, ProjectsPage, SchemasPage, SettingsPage
- Fix tsconfig.node.json

Deployment:
- Update config.prod.yaml: single-binary SPA layout at /opt/silo
- Update silod.service: ReadOnlyPaths for /opt/silo
- Add scripts/deploy.sh: build, package, ship, migrate, start
- Update docker-compose.yaml and Dockerfile
- Add frontend-spec.md design document
2026-02-07 13:35:22 -06:00

1669 lines
49 KiB
Go

package api
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"github.com/alexedwards/scs/v2"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/kindredsystems/silo/internal/auth"
"github.com/kindredsystems/silo/internal/config"
"github.com/kindredsystems/silo/internal/db"
"github.com/kindredsystems/silo/internal/partnum"
"github.com/kindredsystems/silo/internal/schema"
"github.com/kindredsystems/silo/internal/storage"
"github.com/rs/zerolog"
"gopkg.in/yaml.v3"
)
// Server holds dependencies for HTTP handlers.
type Server struct {
logger zerolog.Logger
db *db.DB
items *db.ItemRepository
projects *db.ProjectRepository
relationships *db.RelationshipRepository
schemas map[string]*schema.Schema
schemasDir string
partgen *partnum.Generator
storage *storage.Storage
auth *auth.Service
sessions *scs.SessionManager
oidc *auth.OIDCBackend
authConfig *config.AuthConfig
itemFiles *db.ItemFileRepository
}
// NewServer creates a new API server.
func NewServer(
logger zerolog.Logger,
database *db.DB,
schemas map[string]*schema.Schema,
schemasDir string,
store *storage.Storage,
authService *auth.Service,
sessionManager *scs.SessionManager,
oidcBackend *auth.OIDCBackend,
authCfg *config.AuthConfig,
) *Server {
items := db.NewItemRepository(database)
projects := db.NewProjectRepository(database)
relationships := db.NewRelationshipRepository(database)
itemFiles := db.NewItemFileRepository(database)
seqStore := &dbSequenceStore{db: database, schemas: schemas}
partgen := partnum.NewGenerator(schemas, seqStore)
return &Server{
logger: logger,
db: database,
items: items,
projects: projects,
relationships: relationships,
schemas: schemas,
schemasDir: schemasDir,
partgen: partgen,
storage: store,
auth: authService,
sessions: sessionManager,
oidc: oidcBackend,
authConfig: authCfg,
itemFiles: itemFiles,
}
}
// dbSequenceStore implements partnum.SequenceStore using the database.
type dbSequenceStore struct {
db *db.DB
schemas map[string]*schema.Schema
}
func (s *dbSequenceStore) NextValue(ctx context.Context, schemaName string, scope string) (int, error) {
// For now, use schema name as ID. In production, you'd look up the schema UUID.
return s.db.NextSequenceValue(ctx, schemaName, scope)
}
// Error response structure.
type ErrorResponse struct {
Error string `json:"error"`
Message string `json:"message,omitempty"`
}
// writeJSON writes a JSON response.
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(v)
}
// writeError writes an error JSON response.
func writeError(w http.ResponseWriter, status int, err string, message string) {
writeJSON(w, status, ErrorResponse{Error: err, Message: message})
}
// Health check handlers
// HandleHealth returns basic health status.
func (s *Server) HandleHealth(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
// HandleReady checks database and storage connectivity.
func (s *Server) HandleReady(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// Check database
if err := s.db.Pool().Ping(ctx); err != nil {
writeError(w, http.StatusServiceUnavailable, "database_unavailable", err.Error())
return
}
// Storage check would go here if we had a ping method
writeJSON(w, http.StatusOK, map[string]string{
"status": "ready",
"database": "ok",
"storage": "ok",
})
}
// Schema handlers
// SchemaResponse represents a schema in API responses.
type SchemaResponse struct {
Name string `json:"name"`
Version int `json:"version"`
Description string `json:"description"`
Separator string `json:"separator"`
Format string `json:"format"`
Segments []SegmentResponse `json:"segments"`
Examples []string `json:"examples,omitempty"`
}
// SegmentResponse represents a schema segment.
type SegmentResponse struct {
Name string `json:"name"`
Type string `json:"type"`
Description string `json:"description,omitempty"`
Required bool `json:"required"`
Values map[string]string `json:"values,omitempty"`
Length int `json:"length,omitempty"`
}
// HandleListSchemas lists all available schemas.
func (s *Server) HandleListSchemas(w http.ResponseWriter, r *http.Request) {
schemas := make([]SchemaResponse, 0, len(s.schemas))
for _, sch := range s.schemas {
schemas = append(schemas, schemaToResponse(sch))
}
writeJSON(w, http.StatusOK, schemas)
}
// HandleGetSchema returns a specific schema.
func (s *Server) HandleGetSchema(w http.ResponseWriter, r *http.Request) {
name := chi.URLParam(r, "name")
sch, ok := s.schemas[name]
if !ok {
writeError(w, http.StatusNotFound, "not_found", "Schema not found")
return
}
writeJSON(w, http.StatusOK, schemaToResponse(sch))
}
// HandleGetPropertySchema returns the property schema for a category.
func (s *Server) HandleGetPropertySchema(w http.ResponseWriter, r *http.Request) {
schemaName := chi.URLParam(r, "name")
category := r.URL.Query().Get("category")
sch, ok := s.schemas[schemaName]
if !ok {
writeError(w, http.StatusNotFound, "not_found", "Schema not found")
return
}
if sch.PropertySchemas == nil {
writeJSON(w, http.StatusOK, map[string]any{
"version": 0,
"properties": map[string]any{},
})
return
}
props := sch.PropertySchemas.GetPropertiesForCategory(category)
writeJSON(w, http.StatusOK, map[string]any{
"version": sch.PropertySchemas.Version,
"properties": props,
})
}
func schemaToResponse(sch *schema.Schema) SchemaResponse {
segments := make([]SegmentResponse, len(sch.Segments))
for i, seg := range sch.Segments {
segments[i] = SegmentResponse{
Name: seg.Name,
Type: seg.Type,
Description: seg.Description,
Required: seg.Required,
Values: seg.Values,
Length: seg.Length,
}
}
return SchemaResponse{
Name: sch.Name,
Version: sch.Version,
Description: sch.Description,
Separator: sch.Separator,
Format: sch.Format,
Segments: segments,
Examples: sch.Examples,
}
}
// Item handlers
// ItemResponse represents an item in API responses.
type ItemResponse struct {
ID string `json:"id"`
PartNumber string `json:"part_number"`
ItemType string `json:"item_type"`
Description string `json:"description"`
CurrentRevision int `json:"current_revision"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
SourcingType string `json:"sourcing_type"`
SourcingLink *string `json:"sourcing_link,omitempty"`
LongDescription *string `json:"long_description,omitempty"`
StandardCost *float64 `json:"standard_cost,omitempty"`
ThumbnailKey *string `json:"thumbnail_key,omitempty"`
Properties map[string]any `json:"properties,omitempty"`
}
// CreateItemRequest represents a request to create an item.
type CreateItemRequest struct {
Schema string `json:"schema"`
Category string `json:"category"`
Description string `json:"description"`
Projects []string `json:"projects,omitempty"`
Properties map[string]any `json:"properties,omitempty"`
SourcingType string `json:"sourcing_type,omitempty"`
SourcingLink *string `json:"sourcing_link,omitempty"`
LongDescription *string `json:"long_description,omitempty"`
StandardCost *float64 `json:"standard_cost,omitempty"`
}
// HandleListItems lists items with optional filtering.
func (s *Server) HandleListItems(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
opts := db.ListOptions{
ItemType: r.URL.Query().Get("type"),
Search: r.URL.Query().Get("search"),
Project: r.URL.Query().Get("project"),
}
if limit := r.URL.Query().Get("limit"); limit != "" {
if l, err := strconv.Atoi(limit); err == nil {
opts.Limit = l
}
}
if offset := r.URL.Query().Get("offset"); offset != "" {
if o, err := strconv.Atoi(offset); err == nil {
opts.Offset = o
}
}
items, err := s.items.List(ctx, opts)
if err != nil {
s.logger.Error().Err(err).Msg("failed to list items")
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list items")
return
}
response := make([]ItemResponse, len(items))
for i, item := range items {
response[i] = itemToResponse(item)
}
writeJSON(w, http.StatusOK, response)
}
// HandleFuzzySearch performs fuzzy search across items.
func (s *Server) HandleFuzzySearch(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
q := r.URL.Query().Get("q")
if q == "" {
writeJSON(w, http.StatusOK, []FuzzyResult{})
return
}
fieldsParam := r.URL.Query().Get("fields")
var fields []string
if fieldsParam != "" {
fields = strings.Split(fieldsParam, ",")
}
limit := 50
if l := r.URL.Query().Get("limit"); l != "" {
if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 {
limit = parsed
}
}
// Pre-filter by type and project via SQL (no search term)
opts := db.ListOptions{
ItemType: r.URL.Query().Get("type"),
Project: r.URL.Query().Get("project"),
Limit: 500, // reasonable upper bound for fuzzy matching
}
items, err := s.items.List(ctx, opts)
if err != nil {
s.logger.Error().Err(err).Msg("failed to list items for fuzzy search")
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to search items")
return
}
responses := make([]ItemResponse, len(items))
for i, item := range items {
responses[i] = itemToResponse(item)
}
results := FuzzySearch(q, responses, fields, limit)
writeJSON(w, http.StatusOK, results)
}
// HandleCreateItem creates a new item with generated part number.
func (s *Server) HandleCreateItem(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
var req CreateItemRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_json", err.Error())
return
}
// Default schema
schemaName := req.Schema
if schemaName == "" {
schemaName = "kindred-rd"
}
// Generate part number (no longer includes project)
input := partnum.Input{
SchemaName: schemaName,
Values: map[string]string{
"category": req.Category,
},
}
// Determine item type from category
itemType := "part"
if len(req.Category) > 0 {
switch req.Category[0] {
case 'A':
itemType = "assembly"
case 'T':
itemType = "tooling"
}
}
properties := req.Properties
if properties == nil {
properties = make(map[string]any)
}
properties["category"] = req.Category
// Retry loop: if the generated part number collides with an existing
// item (sequence counter out of sync), generate a new one and retry.
const maxRetries = 5
var item *db.Item
for attempt := 0; attempt < maxRetries; attempt++ {
partNumber, err := s.partgen.Generate(ctx, input)
if err != nil {
s.logger.Error().Err(err).Msg("failed to generate part number")
writeError(w, http.StatusBadRequest, "generation_failed", err.Error())
return
}
item = &db.Item{
PartNumber: partNumber,
ItemType: itemType,
Description: req.Description,
SourcingType: req.SourcingType,
SourcingLink: req.SourcingLink,
LongDescription: req.LongDescription,
StandardCost: req.StandardCost,
}
if user := auth.UserFromContext(ctx); user != nil {
item.CreatedBy = &user.Username
}
err = s.items.Create(ctx, item, properties)
if err == nil {
break // success
}
// Check if this is a duplicate key error — retry with next sequence
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
s.logger.Warn().
Str("part_number", partNumber).
Int("attempt", attempt+1).
Msg("duplicate part number, retrying with next sequence value")
continue
}
// Non-duplicate error, fail immediately
s.logger.Error().Err(err).Msg("failed to create item")
writeError(w, http.StatusInternalServerError, "create_failed", err.Error())
return
}
if item == nil || item.ID == "" {
s.logger.Error().Int("retries", maxRetries).Msg("exhausted retries for part number generation")
writeError(w, http.StatusConflict, "duplicate_part_number", "Could not generate a unique part number after multiple attempts")
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))
}
// HandleGetItem retrieves an item by part number.
// Supports query param: ?include=properties to include current revision properties.
func (s *Server) HandleGetItem(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
partNumber := chi.URLParam(r, "partNumber")
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
}
response := itemToResponse(item)
// Include properties from current revision if requested
if r.URL.Query().Get("include") == "properties" {
revisions, err := s.items.GetRevisions(ctx, item.ID)
if err == nil {
for _, rev := range revisions {
if rev.RevisionNumber == item.CurrentRevision {
response.Properties = rev.Properties
break
}
}
}
}
writeJSON(w, http.StatusOK, response)
}
// UpdateItemRequest represents a request to update an item.
type UpdateItemRequest struct {
PartNumber string `json:"part_number,omitempty"`
ItemType string `json:"item_type,omitempty"`
Description string `json:"description,omitempty"`
Properties map[string]any `json:"properties,omitempty"`
Comment string `json:"comment,omitempty"`
SourcingType *string `json:"sourcing_type,omitempty"`
SourcingLink *string `json:"sourcing_link,omitempty"`
LongDescription *string `json:"long_description,omitempty"`
StandardCost *float64 `json:"standard_cost,omitempty"`
}
// HandleUpdateItem updates an item's fields and/or creates a new revision.
func (s *Server) HandleUpdateItem(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
partNumber := chi.URLParam(r, "partNumber")
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 UpdateItemRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_json", err.Error())
return
}
// Update item fields if provided
fields := db.UpdateItemFields{
PartNumber: item.PartNumber,
ItemType: item.ItemType,
Description: item.Description,
SourcingType: req.SourcingType,
SourcingLink: req.SourcingLink,
LongDescription: req.LongDescription,
StandardCost: req.StandardCost,
}
if req.PartNumber != "" {
fields.PartNumber = req.PartNumber
}
if req.ItemType != "" {
fields.ItemType = req.ItemType
}
if req.Description != "" {
fields.Description = req.Description
}
// Update the item record (UUID stays the same)
if user := auth.UserFromContext(ctx); user != nil {
fields.UpdatedBy = &user.Username
}
if err := s.items.Update(ctx, item.ID, fields); err != nil {
s.logger.Error().Err(err).Msg("failed to update item")
writeError(w, http.StatusInternalServerError, "update_failed", err.Error())
return
}
// Create new revision if properties provided
if req.Properties != nil {
rev := &db.Revision{
ItemID: item.ID,
Properties: req.Properties,
Comment: &req.Comment,
}
if user := auth.UserFromContext(ctx); user != nil {
rev.CreatedBy = &user.Username
}
if err := s.items.CreateRevision(ctx, rev); err != nil {
s.logger.Error().Err(err).Msg("failed to create revision")
writeError(w, http.StatusInternalServerError, "revision_failed", err.Error())
return
}
}
// Get updated item (use new part number if changed)
item, _ = s.items.GetByPartNumber(ctx, fields.PartNumber)
writeJSON(w, http.StatusOK, itemToResponse(item))
}
// HandleDeleteItem permanently deletes an item.
// Use query param ?soft=true for soft delete (archive).
func (s *Server) HandleDeleteItem(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
partNumber := chi.URLParam(r, "partNumber")
soft := r.URL.Query().Get("soft") == "true"
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
}
if soft {
if err := s.items.Archive(ctx, item.ID); err != nil {
s.logger.Error().Err(err).Msg("failed to archive item")
writeError(w, http.StatusInternalServerError, "archive_failed", err.Error())
return
}
} else {
if err := s.items.Delete(ctx, item.ID); err != nil {
s.logger.Error().Err(err).Msg("failed to delete item")
writeError(w, http.StatusInternalServerError, "delete_failed", err.Error())
return
}
}
w.WriteHeader(http.StatusNoContent)
}
// Revision handlers
// RevisionResponse represents a revision in API responses.
type RevisionResponse struct {
ID string `json:"id"`
RevisionNumber int `json:"revision_number"`
Properties map[string]any `json:"properties"`
FileKey *string `json:"file_key,omitempty"`
FileChecksum *string `json:"file_checksum,omitempty"`
FileSize *int64 `json:"file_size,omitempty"`
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.
func (s *Server) HandleListRevisions(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
partNumber := chi.URLParam(r, "partNumber")
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
}
revisions, err := s.items.GetRevisions(ctx, item.ID)
if err != nil {
s.logger.Error().Err(err).Msg("failed to get revisions")
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get revisions")
return
}
response := make([]RevisionResponse, len(revisions))
for i, rev := range revisions {
response[i] = revisionToResponse(rev)
}
writeJSON(w, http.StatusOK, response)
}
// HandleGetRevision retrieves a specific revision.
func (s *Server) HandleGetRevision(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 || item == nil {
writeError(w, http.StatusNotFound, "not_found", "Item not found")
return
}
revisions, err := s.items.GetRevisions(ctx, item.ID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get revisions")
return
}
for _, rev := range revisions {
if rev.RevisionNumber == revNum {
writeJSON(w, http.StatusOK, revisionToResponse(rev))
return
}
}
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)
}
var createdBy *string
if user := auth.UserFromContext(ctx); user != nil {
createdBy = &user.Username
}
newRev, err := s.items.CreateRevisionFromExisting(ctx, item.ID, revNum, comment, createdBy)
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.
type GeneratePartNumberRequest struct {
Schema string `json:"schema"`
Project string `json:"project"`
Category string `json:"category"`
}
// HandleGeneratePartNumber generates a part number without creating an item.
func (s *Server) HandleGeneratePartNumber(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
var req GeneratePartNumberRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_json", err.Error())
return
}
schemaName := req.Schema
if schemaName == "" {
schemaName = "kindred-rd"
}
input := partnum.Input{
SchemaName: schemaName,
Values: map[string]string{
"project": req.Project,
"category": req.Category,
},
}
partNumber, err := s.partgen.Generate(ctx, input)
if err != nil {
writeError(w, http.StatusBadRequest, "generation_failed", err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"part_number": partNumber})
}
// Schema value management handlers
// AddSchemaValueRequest represents a request to add a new enum value.
type AddSchemaValueRequest struct {
Code string `json:"code"`
Description string `json:"description"`
}
// UpdateSchemaValueRequest represents a request to update an enum value description.
type UpdateSchemaValueRequest struct {
Description string `json:"description"`
}
// HandleAddSchemaValue adds a new value to an enum segment.
func (s *Server) HandleAddSchemaValue(w http.ResponseWriter, r *http.Request) {
schemaName := chi.URLParam(r, "name")
segmentName := chi.URLParam(r, "segment")
sch, ok := s.schemas[schemaName]
if !ok {
writeError(w, http.StatusNotFound, "not_found", "Schema not found")
return
}
// Find the segment
var segment *schema.Segment
for i := range sch.Segments {
if sch.Segments[i].Name == segmentName {
segment = &sch.Segments[i]
break
}
}
if segment == nil {
writeError(w, http.StatusNotFound, "not_found", "Segment not found")
return
}
if segment.Type != "enum" {
writeError(w, http.StatusBadRequest, "invalid_segment", "Segment is not an enum type")
return
}
var req AddSchemaValueRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_json", err.Error())
return
}
if req.Code == "" || req.Description == "" {
writeError(w, http.StatusBadRequest, "invalid_request", "Code and description are required")
return
}
// Check if code already exists
if _, exists := segment.Values[req.Code]; exists {
writeError(w, http.StatusConflict, "already_exists", "Value code already exists")
return
}
// Add the new value
segment.Values[req.Code] = req.Description
// Save to file
if err := s.saveSchema(sch); err != nil {
s.logger.Error().Err(err).Msg("failed to save schema")
writeError(w, http.StatusInternalServerError, "save_failed", err.Error())
return
}
writeJSON(w, http.StatusCreated, map[string]string{"code": req.Code, "description": req.Description})
}
// HandleUpdateSchemaValue updates an enum value's description.
func (s *Server) HandleUpdateSchemaValue(w http.ResponseWriter, r *http.Request) {
schemaName := chi.URLParam(r, "name")
segmentName := chi.URLParam(r, "segment")
code := chi.URLParam(r, "code")
sch, ok := s.schemas[schemaName]
if !ok {
writeError(w, http.StatusNotFound, "not_found", "Schema not found")
return
}
// Find the segment
var segment *schema.Segment
for i := range sch.Segments {
if sch.Segments[i].Name == segmentName {
segment = &sch.Segments[i]
break
}
}
if segment == nil {
writeError(w, http.StatusNotFound, "not_found", "Segment not found")
return
}
if segment.Type != "enum" {
writeError(w, http.StatusBadRequest, "invalid_segment", "Segment is not an enum type")
return
}
// Check if code exists
if _, exists := segment.Values[code]; !exists {
writeError(w, http.StatusNotFound, "not_found", "Value code not found")
return
}
var req UpdateSchemaValueRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_json", err.Error())
return
}
if req.Description == "" {
writeError(w, http.StatusBadRequest, "invalid_request", "Description is required")
return
}
// Update the value
segment.Values[code] = req.Description
// Save to file
if err := s.saveSchema(sch); err != nil {
s.logger.Error().Err(err).Msg("failed to save schema")
writeError(w, http.StatusInternalServerError, "save_failed", err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"code": code, "description": req.Description})
}
// HandleDeleteSchemaValue removes an enum value.
func (s *Server) HandleDeleteSchemaValue(w http.ResponseWriter, r *http.Request) {
schemaName := chi.URLParam(r, "name")
segmentName := chi.URLParam(r, "segment")
code := chi.URLParam(r, "code")
sch, ok := s.schemas[schemaName]
if !ok {
writeError(w, http.StatusNotFound, "not_found", "Schema not found")
return
}
// Find the segment
var segment *schema.Segment
for i := range sch.Segments {
if sch.Segments[i].Name == segmentName {
segment = &sch.Segments[i]
break
}
}
if segment == nil {
writeError(w, http.StatusNotFound, "not_found", "Segment not found")
return
}
if segment.Type != "enum" {
writeError(w, http.StatusBadRequest, "invalid_segment", "Segment is not an enum type")
return
}
// Check if code exists
if _, exists := segment.Values[code]; !exists {
writeError(w, http.StatusNotFound, "not_found", "Value code not found")
return
}
// Delete the value
delete(segment.Values, code)
// Save to file
if err := s.saveSchema(sch); err != nil {
s.logger.Error().Err(err).Msg("failed to save schema")
writeError(w, http.StatusInternalServerError, "save_failed", err.Error())
return
}
w.WriteHeader(http.StatusNoContent)
}
// saveSchema writes the schema back to its YAML file.
func (s *Server) saveSchema(sch *schema.Schema) error {
// Build the schema file structure
schemaFile := schema.SchemaFile{
Schema: *sch,
}
data, err := yaml.Marshal(schemaFile)
if err != nil {
return err
}
filename := filepath.Join(s.schemasDir, sch.Name+".yaml")
return os.WriteFile(filename, data, 0644)
}
// Helper functions
func itemToResponse(item *db.Item) ItemResponse {
return ItemResponse{
ID: item.ID,
PartNumber: item.PartNumber,
ItemType: item.ItemType,
Description: item.Description,
CurrentRevision: item.CurrentRevision,
CreatedAt: item.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
UpdatedAt: item.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"),
SourcingType: item.SourcingType,
SourcingLink: item.SourcingLink,
LongDescription: item.LongDescription,
StandardCost: item.StandardCost,
ThumbnailKey: item.ThumbnailKey,
}
}
func revisionToResponse(rev *db.Revision) RevisionResponse {
labels := rev.Labels
if labels == nil {
labels = []string{}
}
return RevisionResponse{
ID: rev.ID,
RevisionNumber: rev.RevisionNumber,
Properties: rev.Properties,
FileKey: rev.FileKey,
FileChecksum: rev.FileChecksum,
FileSize: rev.FileSize,
CreatedAt: rev.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
CreatedBy: rev.CreatedBy,
Comment: rev.Comment,
Status: rev.Status,
Labels: labels,
}
}
// File upload/download handlers
// CreateRevisionRequest represents a request to create a new revision.
type CreateRevisionRequest struct {
Properties map[string]any `json:"properties"`
Comment string `json:"comment"`
}
// HandleCreateRevision creates a new revision for an item (without file).
func (s *Server) HandleCreateRevision(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
partNumber := chi.URLParam(r, "partNumber")
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 CreateRevisionRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_json", err.Error())
return
}
rev := &db.Revision{
ItemID: item.ID,
Properties: req.Properties,
Comment: &req.Comment,
}
if user := auth.UserFromContext(ctx); user != nil {
rev.CreatedBy = &user.Username
}
if err := s.items.CreateRevision(ctx, rev); err != nil {
s.logger.Error().Err(err).Msg("failed to create revision")
writeError(w, http.StatusInternalServerError, "revision_failed", err.Error())
return
}
writeJSON(w, http.StatusCreated, revisionToResponse(rev))
}
// HandleUploadFile uploads a file and creates a new revision.
func (s *Server) HandleUploadFile(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
partNumber := chi.URLParam(r, "partNumber")
// Check storage is configured
if s.storage == nil {
writeError(w, http.StatusServiceUnavailable, "storage_unavailable", "File storage not configured")
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
}
// Parse multipart form (max 100MB)
if err := r.ParseMultipartForm(100 << 20); err != nil {
writeError(w, http.StatusBadRequest, "invalid_form", err.Error())
return
}
// Get the file
file, header, err := r.FormFile("file")
if err != nil {
writeError(w, http.StatusBadRequest, "missing_file", "File is required")
return
}
defer file.Close()
// Get optional fields
comment := r.FormValue("comment")
propertiesJSON := r.FormValue("properties")
var properties map[string]any
if propertiesJSON != "" {
if err := json.Unmarshal([]byte(propertiesJSON), &properties); err != nil {
writeError(w, http.StatusBadRequest, "invalid_properties", "Properties must be valid JSON")
return
}
} else {
properties = make(map[string]any)
}
// Determine the next revision number
nextRevision := item.CurrentRevision + 1
// Generate storage key
fileKey := storage.FileKey(partNumber, nextRevision)
// Determine content type
contentType := header.Header.Get("Content-Type")
if contentType == "" {
contentType = "application/octet-stream"
}
// Upload to storage
result, err := s.storage.Put(ctx, fileKey, file, header.Size, contentType)
if err != nil {
s.logger.Error().Err(err).Msg("failed to upload file")
writeError(w, http.StatusInternalServerError, "upload_failed", err.Error())
return
}
// Create revision with file metadata
rev := &db.Revision{
ItemID: item.ID,
Properties: properties,
FileKey: &result.Key,
FileVersion: &result.VersionID,
FileChecksum: &result.Checksum,
FileSize: &result.Size,
Comment: &comment,
}
if user := auth.UserFromContext(ctx); user != nil {
rev.CreatedBy = &user.Username
}
if err := s.items.CreateRevision(ctx, rev); err != nil {
s.logger.Error().Err(err).Msg("failed to create revision")
writeError(w, http.StatusInternalServerError, "revision_failed", err.Error())
return
}
s.logger.Info().
Str("part_number", partNumber).
Int("revision", rev.RevisionNumber).
Str("file_key", fileKey).
Int64("size", result.Size).
Msg("file uploaded")
writeJSON(w, http.StatusCreated, revisionToResponse(rev))
}
// HandleDownloadFile downloads the file for a specific revision.
func (s *Server) HandleDownloadFile(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
partNumber := chi.URLParam(r, "partNumber")
revStr := chi.URLParam(r, "revision")
// Check storage is configured
if s.storage == nil {
writeError(w, http.StatusServiceUnavailable, "storage_unavailable", "File storage not configured")
return
}
item, err := s.items.GetByPartNumber(ctx, partNumber)
if err != nil || item == nil {
writeError(w, http.StatusNotFound, "not_found", "Item not found")
return
}
// Parse revision number (or use "latest")
var revNum int
if revStr == "latest" {
revNum = item.CurrentRevision
} else {
revNum, err = strconv.Atoi(revStr)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_revision", "Revision must be a number or 'latest'")
return
}
}
// Get revision to find file key
revisions, err := s.items.GetRevisions(ctx, item.ID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get revisions")
return
}
var revision *db.Revision
for _, rev := range revisions {
if rev.RevisionNumber == revNum {
revision = rev
break
}
}
if revision == nil {
writeError(w, http.StatusNotFound, "not_found", "Revision not found")
return
}
if revision.FileKey == nil {
writeError(w, http.StatusNotFound, "no_file", "Revision has no associated file")
return
}
// Get file from storage
var reader interface {
Read(p []byte) (n int, err error)
Close() error
}
if revision.FileVersion != nil && *revision.FileVersion != "" {
reader, err = s.storage.GetVersion(ctx, *revision.FileKey, *revision.FileVersion)
} else {
reader, err = s.storage.Get(ctx, *revision.FileKey)
}
if err != nil {
s.logger.Error().Err(err).Str("key", *revision.FileKey).Msg("failed to get file")
writeError(w, http.StatusInternalServerError, "download_failed", err.Error())
return
}
defer reader.Close()
// Set response headers
filename := partNumber + "_rev" + strconv.Itoa(revNum) + ".FCStd"
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Content-Disposition", "attachment; filename=\""+filename+"\"")
if revision.FileSize != nil {
w.Header().Set("Content-Length", strconv.FormatInt(*revision.FileSize, 10))
}
// Stream file to response
buf := make([]byte, 32*1024)
for {
n, readErr := reader.Read(buf)
if n > 0 {
if _, writeErr := w.Write(buf[:n]); writeErr != nil {
s.logger.Error().Err(writeErr).Msg("failed to write response")
return
}
}
if readErr != nil {
break
}
}
}
// HandleDownloadLatestFile downloads the file for the latest revision.
func (s *Server) HandleDownloadLatestFile(w http.ResponseWriter, r *http.Request) {
chi.URLParam(r, "partNumber") // ensure URL param is consumed
r = r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, chi.RouteContext(r.Context())))
// Add "latest" as the revision param and delegate
rctx := chi.RouteContext(r.Context())
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 user := auth.UserFromContext(ctx); user != nil {
project.CreatedBy = &user.Username
}
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"),
}
}