Add server-sent events at GET /api/events for live mutation notifications. Add server mode (normal/read-only/degraded) exposed via /health, /ready, and SSE server.state events. New files: - broker.go: SSE event hub with client management, non-blocking fan-out, ring buffer history for Last-Event-ID replay, heartbeat - servermode.go: mode state machine with periodic MinIO health check and SIGUSR1 read-only toggle - sse_handler.go: HTTP handler using http.Flusher and ResponseController to disable WriteTimeout for long-lived SSE - broker_test.go, servermode_test.go: 13 unit tests Modified: - handlers.go: Server struct gains broker/serverState fields, Health/Ready include mode and sse_clients, write handlers emit item.created/updated/deleted and revision.created events - routes.go: register GET /api/events, add RequireWritable middleware to all 8 editor-gated route groups - middleware.go: RequireWritable returns 503 in read-only mode - csv.go, ods.go: emit bulk item.created events after import - storage.go: add Ping() method for health checks - config.go: add ReadOnly field to ServerConfig - main.go: create broker/state, start background goroutines, SIGUSR1 handler, graceful shutdown sequence Closes #38, closes #39
1702 lines
50 KiB
Go
1702 lines
50 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
|
|
broker *Broker
|
|
serverState *ServerState
|
|
}
|
|
|
|
// 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,
|
|
broker *Broker,
|
|
state *ServerState,
|
|
) *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,
|
|
broker: broker,
|
|
serverState: state,
|
|
}
|
|
}
|
|
|
|
// 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",
|
|
"mode": string(s.serverState.Mode()),
|
|
})
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
storageStatus := "ok"
|
|
if s.storage != nil {
|
|
if err := s.storage.Ping(ctx); err != nil {
|
|
storageStatus = "unavailable"
|
|
}
|
|
} else {
|
|
storageStatus = "not_configured"
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"status": "ready",
|
|
"database": "ok",
|
|
"storage": storageStatus,
|
|
"mode": string(s.serverState.Mode()),
|
|
"sse_clients": s.broker.ClientCount(),
|
|
})
|
|
}
|
|
|
|
// 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")
|
|
}
|
|
}
|
|
}
|
|
|
|
resp := itemToResponse(item)
|
|
writeJSON(w, http.StatusCreated, resp)
|
|
s.broker.Publish("item.created", mustMarshal(resp))
|
|
}
|
|
|
|
// 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)
|
|
resp := itemToResponse(item)
|
|
writeJSON(w, http.StatusOK, resp)
|
|
s.broker.Publish("item.updated", mustMarshal(resp))
|
|
}
|
|
|
|
// 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)
|
|
s.broker.Publish("item.deleted", mustMarshal(map[string]string{"part_number": partNumber}))
|
|
}
|
|
|
|
// 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))
|
|
s.broker.Publish("revision.created", mustMarshal(map[string]any{
|
|
"part_number": partNumber,
|
|
"revision_number": newRev.RevisionNumber,
|
|
"rollback_from": revNum,
|
|
}))
|
|
}
|
|
|
|
// 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))
|
|
s.broker.Publish("revision.created", mustMarshal(map[string]any{
|
|
"part_number": partNumber,
|
|
"revision_number": rev.RevisionNumber,
|
|
}))
|
|
}
|
|
|
|
// 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"),
|
|
}
|
|
}
|