- Add 022_workstations.sql migration (UUID PK, user_id FK, UNIQUE(user_id, name)) - Add Sessions module (depends on Auth, default enabled) with config toggle - Add WorkstationRepository with Upsert, GetByID, ListByUser, Touch, Delete - Add workstation handlers: register (POST upsert), list (GET), delete (DELETE) - Add /api/workstations routes gated by sessions module - Wire WorkstationRepository into Server struct - Update module tests for new Sessions module Closes #161
2065 lines
61 KiB
Go
2065 lines
61 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"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/jobdef"
|
|
"github.com/kindredsystems/silo/internal/kc"
|
|
"github.com/kindredsystems/silo/internal/modules"
|
|
"github.com/kindredsystems/silo/internal/partnum"
|
|
"github.com/kindredsystems/silo/internal/schema"
|
|
"github.com/kindredsystems/silo/internal/storage"
|
|
"github.com/kindredsystems/silo/internal/workflow"
|
|
"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.FileStore
|
|
auth *auth.Service
|
|
sessions *scs.SessionManager
|
|
oidc *auth.OIDCBackend
|
|
authConfig *config.AuthConfig
|
|
itemFiles *db.ItemFileRepository
|
|
broker *Broker
|
|
serverState *ServerState
|
|
dag *db.DAGRepository
|
|
jobs *db.JobRepository
|
|
locations *db.LocationRepository
|
|
jobDefs map[string]*jobdef.Definition
|
|
jobDefsDir string
|
|
modules *modules.Registry
|
|
cfg *config.Config
|
|
settings *db.SettingsRepository
|
|
metadata *db.ItemMetadataRepository
|
|
deps *db.ItemDependencyRepository
|
|
macros *db.ItemMacroRepository
|
|
approvals *db.ItemApprovalRepository
|
|
workflows map[string]*workflow.Workflow
|
|
solverResults *db.SolverResultRepository
|
|
workstations *db.WorkstationRepository
|
|
}
|
|
|
|
// NewServer creates a new API server.
|
|
func NewServer(
|
|
logger zerolog.Logger,
|
|
database *db.DB,
|
|
schemas map[string]*schema.Schema,
|
|
schemasDir string,
|
|
store storage.FileStore,
|
|
authService *auth.Service,
|
|
sessionManager *scs.SessionManager,
|
|
oidcBackend *auth.OIDCBackend,
|
|
authCfg *config.AuthConfig,
|
|
broker *Broker,
|
|
state *ServerState,
|
|
jobDefs map[string]*jobdef.Definition,
|
|
jobDefsDir string,
|
|
registry *modules.Registry,
|
|
cfg *config.Config,
|
|
workflows map[string]*workflow.Workflow,
|
|
) *Server {
|
|
items := db.NewItemRepository(database)
|
|
projects := db.NewProjectRepository(database)
|
|
relationships := db.NewRelationshipRepository(database)
|
|
itemFiles := db.NewItemFileRepository(database)
|
|
dag := db.NewDAGRepository(database)
|
|
jobs := db.NewJobRepository(database)
|
|
settings := db.NewSettingsRepository(database)
|
|
locations := db.NewLocationRepository(database)
|
|
metadata := db.NewItemMetadataRepository(database)
|
|
itemDeps := db.NewItemDependencyRepository(database)
|
|
itemMacros := db.NewItemMacroRepository(database)
|
|
itemApprovals := db.NewItemApprovalRepository(database)
|
|
solverResults := db.NewSolverResultRepository(database)
|
|
workstations := db.NewWorkstationRepository(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,
|
|
dag: dag,
|
|
jobs: jobs,
|
|
locations: locations,
|
|
jobDefs: jobDefs,
|
|
jobDefsDir: jobDefsDir,
|
|
modules: registry,
|
|
cfg: cfg,
|
|
settings: settings,
|
|
metadata: metadata,
|
|
deps: itemDeps,
|
|
macros: itemMacros,
|
|
approvals: itemApprovals,
|
|
workflows: workflows,
|
|
solverResults: solverResults,
|
|
workstations: workstations,
|
|
}
|
|
}
|
|
|
|
// 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(),
|
|
})
|
|
}
|
|
|
|
// HandleGetModules returns the public module discovery response.
|
|
// No authentication required — clients call this pre-login.
|
|
func (s *Server) HandleGetModules(w http.ResponseWriter, r *http.Request) {
|
|
mods := make(map[string]any, 10)
|
|
for _, m := range s.modules.All() {
|
|
entry := map[string]any{
|
|
"enabled": s.modules.IsEnabled(m.ID),
|
|
"required": m.Required,
|
|
"name": m.Name,
|
|
}
|
|
if m.Version != "" {
|
|
entry["version"] = m.Version
|
|
}
|
|
if len(m.DependsOn) > 0 {
|
|
entry["depends_on"] = m.DependsOn
|
|
}
|
|
|
|
// Public config (non-secret) for specific modules.
|
|
switch m.ID {
|
|
case "auth":
|
|
if s.cfg != nil {
|
|
entry["config"] = map[string]any{
|
|
"local_enabled": s.cfg.Auth.Local.Enabled,
|
|
"ldap_enabled": s.cfg.Auth.LDAP.Enabled,
|
|
"oidc_enabled": s.cfg.Auth.OIDC.Enabled,
|
|
"oidc_issuer_url": s.cfg.Auth.OIDC.IssuerURL,
|
|
}
|
|
}
|
|
case "freecad":
|
|
if s.cfg != nil {
|
|
entry["config"] = map[string]any{
|
|
"uri_scheme": s.cfg.FreeCAD.URIScheme,
|
|
}
|
|
}
|
|
}
|
|
|
|
mods[m.ID] = entry
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"modules": mods,
|
|
"server": map[string]any{
|
|
"version": "0.2",
|
|
"read_only": s.serverState.IsReadOnly(),
|
|
},
|
|
})
|
|
}
|
|
|
|
// 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))
|
|
}
|
|
|
|
// FormFieldDescriptor describes a single field in the form descriptor response.
|
|
type FormFieldDescriptor struct {
|
|
Name string `json:"name"`
|
|
Type string `json:"type"`
|
|
Widget string `json:"widget,omitempty"`
|
|
Label string `json:"label"`
|
|
Required bool `json:"required,omitempty"`
|
|
Default any `json:"default,omitempty"`
|
|
Unit string `json:"unit,omitempty"`
|
|
Description string `json:"description,omitempty"`
|
|
Options []string `json:"options,omitempty"`
|
|
Currency string `json:"currency,omitempty"`
|
|
|
|
// Item-field specific
|
|
DerivedFromCategory map[string]string `json:"derived_from_category,omitempty"`
|
|
SearchEndpoint string `json:"search_endpoint,omitempty"`
|
|
}
|
|
|
|
// FormFieldGroupDescriptor describes an ordered group of resolved fields.
|
|
type FormFieldGroupDescriptor struct {
|
|
Key string `json:"key"`
|
|
Label string `json:"label"`
|
|
Order int `json:"order"`
|
|
Fields []FormFieldDescriptor `json:"fields"`
|
|
}
|
|
|
|
// HandleGetFormDescriptor returns the full form descriptor for a schema.
|
|
func (s *Server) HandleGetFormDescriptor(w http.ResponseWriter, r *http.Request) {
|
|
schemaName := chi.URLParam(r, "name")
|
|
|
|
sch, ok := s.schemas[schemaName]
|
|
if !ok {
|
|
writeError(w, http.StatusNotFound, "not_found", "Schema not found")
|
|
return
|
|
}
|
|
|
|
result := map[string]any{
|
|
"schema_name": sch.Name,
|
|
"format": sch.Format,
|
|
}
|
|
|
|
// Category picker with auto-derived values_by_domain
|
|
if sch.UI != nil && sch.UI.CategoryPicker != nil {
|
|
picker := map[string]any{
|
|
"style": sch.UI.CategoryPicker.Style,
|
|
}
|
|
|
|
vbd := sch.ValuesByDomain()
|
|
|
|
stages := make([]map[string]any, 0, len(sch.UI.CategoryPicker.Stages)+1)
|
|
for _, stage := range sch.UI.CategoryPicker.Stages {
|
|
stg := map[string]any{
|
|
"name": stage.Name,
|
|
"label": stage.Label,
|
|
}
|
|
if stage.Values != nil {
|
|
stg["values"] = stage.Values
|
|
}
|
|
stages = append(stages, stg)
|
|
}
|
|
|
|
// Auto-add subcategory stage from values_by_domain
|
|
if vbd != nil {
|
|
stages = append(stages, map[string]any{
|
|
"name": "subcategory",
|
|
"label": "Type",
|
|
"values_by_domain": vbd,
|
|
})
|
|
}
|
|
|
|
picker["stages"] = stages
|
|
result["category_picker"] = picker
|
|
}
|
|
|
|
// Item fields
|
|
if sch.UI != nil && sch.UI.ItemFields != nil {
|
|
result["item_fields"] = sch.UI.ItemFields
|
|
}
|
|
|
|
// Resolve field groups into ordered list with full field metadata
|
|
if sch.UI != nil && sch.UI.FieldGroups != nil {
|
|
groups := s.resolveFieldGroups(sch, sch.UI.FieldGroups)
|
|
result["field_groups"] = groups
|
|
}
|
|
|
|
// Category field groups
|
|
if sch.UI != nil && sch.UI.CategoryFieldGroups != nil {
|
|
catGroups := make(map[string][]FormFieldGroupDescriptor)
|
|
for prefix, groups := range sch.UI.CategoryFieldGroups {
|
|
catGroups[prefix] = s.resolveCategoryFieldGroups(sch, prefix, groups)
|
|
}
|
|
result["category_field_groups"] = catGroups
|
|
}
|
|
|
|
// Field overrides (pass through)
|
|
if sch.UI != nil && sch.UI.FieldOverrides != nil {
|
|
result["field_overrides"] = sch.UI.FieldOverrides
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, result)
|
|
}
|
|
|
|
// resolveFieldGroups converts field group definitions into fully resolved descriptors.
|
|
func (s *Server) resolveFieldGroups(sch *schema.Schema, groups map[string]schema.FieldGroup) []FormFieldGroupDescriptor {
|
|
result := make([]FormFieldGroupDescriptor, 0, len(groups))
|
|
for key, group := range groups {
|
|
desc := FormFieldGroupDescriptor{
|
|
Key: key,
|
|
Label: group.Label,
|
|
Order: group.Order,
|
|
}
|
|
for _, fieldName := range group.Fields {
|
|
fd := s.resolveField(sch, fieldName)
|
|
desc.Fields = append(desc.Fields, fd)
|
|
}
|
|
result = append(result, desc)
|
|
}
|
|
// Sort by order
|
|
sort.Slice(result, func(i, j int) bool {
|
|
return result[i].Order < result[j].Order
|
|
})
|
|
return result
|
|
}
|
|
|
|
// resolveCategoryFieldGroups resolves category-specific field groups.
|
|
func (s *Server) resolveCategoryFieldGroups(sch *schema.Schema, prefix string, groups map[string]schema.FieldGroup) []FormFieldGroupDescriptor {
|
|
result := make([]FormFieldGroupDescriptor, 0, len(groups))
|
|
for key, group := range groups {
|
|
desc := FormFieldGroupDescriptor{
|
|
Key: key,
|
|
Label: group.Label,
|
|
Order: group.Order,
|
|
}
|
|
for _, fieldName := range group.Fields {
|
|
fd := s.resolveCategoryField(sch, prefix, fieldName)
|
|
desc.Fields = append(desc.Fields, fd)
|
|
}
|
|
result = append(result, desc)
|
|
}
|
|
sort.Slice(result, func(i, j int) bool {
|
|
return result[i].Order < result[j].Order
|
|
})
|
|
return result
|
|
}
|
|
|
|
// resolveField builds a FormFieldDescriptor from item_fields or property_schemas.defaults.
|
|
func (s *Server) resolveField(sch *schema.Schema, name string) FormFieldDescriptor {
|
|
fd := FormFieldDescriptor{Name: name}
|
|
|
|
// Check item_fields first
|
|
if sch.UI != nil && sch.UI.ItemFields != nil {
|
|
if def, ok := sch.UI.ItemFields[name]; ok {
|
|
fd.Type = def.Type
|
|
fd.Widget = def.Widget
|
|
fd.Label = def.Label
|
|
fd.Required = def.Required
|
|
fd.Default = def.Default
|
|
fd.Options = def.Options
|
|
fd.DerivedFromCategory = def.DerivedFromCategory
|
|
fd.SearchEndpoint = def.SearchEndpoint
|
|
s.applyOverrides(sch, name, &fd)
|
|
return fd
|
|
}
|
|
}
|
|
|
|
// Check property_schemas.defaults
|
|
if sch.PropertySchemas != nil && sch.PropertySchemas.Defaults != nil {
|
|
if def, ok := sch.PropertySchemas.Defaults[name]; ok {
|
|
fd.Type = def.Type
|
|
fd.Label = name // Use field name as label if not overridden
|
|
fd.Default = def.Default
|
|
fd.Unit = def.Unit
|
|
fd.Description = def.Description
|
|
fd.Required = def.Required
|
|
s.applyOverrides(sch, name, &fd)
|
|
return fd
|
|
}
|
|
}
|
|
|
|
// Fallback — field name only
|
|
fd.Label = name
|
|
fd.Type = "string"
|
|
s.applyOverrides(sch, name, &fd)
|
|
return fd
|
|
}
|
|
|
|
// resolveCategoryField builds a FormFieldDescriptor from category-specific property schema.
|
|
func (s *Server) resolveCategoryField(sch *schema.Schema, prefix, name string) FormFieldDescriptor {
|
|
fd := FormFieldDescriptor{Name: name, Label: name, Type: "string"}
|
|
|
|
if sch.PropertySchemas != nil {
|
|
if catProps, ok := sch.PropertySchemas.Categories[prefix]; ok {
|
|
if def, ok := catProps[name]; ok {
|
|
fd.Type = def.Type
|
|
fd.Default = def.Default
|
|
fd.Unit = def.Unit
|
|
fd.Description = def.Description
|
|
fd.Required = def.Required
|
|
}
|
|
}
|
|
}
|
|
|
|
s.applyOverrides(sch, name, &fd)
|
|
return fd
|
|
}
|
|
|
|
// applyOverrides applies field_overrides to a field descriptor.
|
|
func (s *Server) applyOverrides(sch *schema.Schema, name string, fd *FormFieldDescriptor) {
|
|
if sch.UI == nil || sch.UI.FieldOverrides == nil {
|
|
return
|
|
}
|
|
ov, ok := sch.UI.FieldOverrides[name]
|
|
if !ok {
|
|
return
|
|
}
|
|
if ov.Widget != "" {
|
|
fd.Widget = ov.Widget
|
|
}
|
|
if ov.Currency != "" {
|
|
fd.Currency = ov.Currency
|
|
}
|
|
if len(ov.Options) > 0 {
|
|
fd.Options = ov.Options
|
|
}
|
|
}
|
|
|
|
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"`
|
|
LongDescription *string `json:"long_description,omitempty"`
|
|
ThumbnailKey *string `json:"thumbnail_key,omitempty"`
|
|
FileCount int `json:"file_count"`
|
|
FilesTotalSize int64 `json:"files_total_size"`
|
|
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"`
|
|
LongDescription *string `json:"long_description,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
|
|
}
|
|
|
|
// Batch-fetch file attachment stats
|
|
ids := make([]string, len(items))
|
|
for i, item := range items {
|
|
ids[i] = item.ID
|
|
}
|
|
fileStats, _ := s.items.BatchGetFileStats(ctx, ids)
|
|
|
|
response := make([]ItemResponse, len(items))
|
|
for i, item := range items {
|
|
response[i] = itemToResponse(item)
|
|
if fs, ok := fileStats[item.ID]; ok {
|
|
response[i].FileCount = fs.Count
|
|
response[i].FilesTotalSize = fs.TotalSize
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
if err := s.partgen.Validate(partNumber, schemaName); err != nil {
|
|
s.logger.Error().Err(err).Str("part_number", partNumber).Msg("generated part number failed validation")
|
|
writeError(w, http.StatusInternalServerError, "validation_failed", err.Error())
|
|
return
|
|
}
|
|
|
|
item = &db.Item{
|
|
PartNumber: partNumber,
|
|
ItemType: itemType,
|
|
Description: req.Description,
|
|
SourcingType: req.SourcingType,
|
|
LongDescription: req.LongDescription,
|
|
}
|
|
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))
|
|
}
|
|
|
|
// HandleGetItemByUUID retrieves an item by its stable UUID (the items.id column).
|
|
// Used by silo-mod to resolve FreeCAD document SiloUUID properties to part numbers.
|
|
func (s *Server) HandleGetItemByUUID(w http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
uuid := chi.URLParam(r, "uuid")
|
|
|
|
item, err := s.items.GetByID(ctx, uuid)
|
|
if err != nil {
|
|
s.logger.Error().Err(err).Msg("failed to get item by UUID")
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get item")
|
|
return
|
|
}
|
|
if item == nil || item.ArchivedAt != nil {
|
|
writeError(w, http.StatusNotFound, "not_found", "Item not found")
|
|
return
|
|
}
|
|
|
|
response := itemToResponse(item)
|
|
if fileStats, err := s.items.BatchGetFileStats(ctx, []string{item.ID}); err == nil {
|
|
if fs, ok := fileStats[item.ID]; ok {
|
|
response.FileCount = fs.Count
|
|
response.FilesTotalSize = fs.TotalSize
|
|
}
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, response)
|
|
}
|
|
|
|
// 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)
|
|
|
|
// File attachment stats
|
|
if fileStats, err := s.items.BatchGetFileStats(ctx, []string{item.ID}); err == nil {
|
|
if fs, ok := fileStats[item.ID]; ok {
|
|
response.FileCount = fs.Count
|
|
response.FilesTotalSize = fs.TotalSize
|
|
}
|
|
}
|
|
|
|
// 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"`
|
|
LongDescription *string `json:"long_description,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,
|
|
LongDescription: req.LongDescription,
|
|
}
|
|
|
|
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,
|
|
LongDescription: item.LongDescription,
|
|
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,
|
|
}))
|
|
|
|
// Trigger auto-jobs (e.g. validation, export)
|
|
go s.triggerJobs(context.Background(), "revision_created", item.ID, item)
|
|
}
|
|
|
|
// 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")
|
|
|
|
// .kc metadata extraction (best-effort)
|
|
s.extractKCMetadata(ctx, item, fileKey, rev)
|
|
|
|
writeJSON(w, http.StatusCreated, revisionToResponse(rev))
|
|
}
|
|
|
|
// HandleDownloadFile downloads the file for a specific revision.
|
|
// For .kc files, silo/ entries are repacked with current DB state.
|
|
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
|
|
}
|
|
|
|
// ETag: computed from revision + metadata freshness.
|
|
meta, _ := s.metadata.Get(ctx, item.ID) // nil is ok (plain .fcstd)
|
|
etag := computeETag(revision, meta)
|
|
|
|
if match := r.Header.Get("If-None-Match"); match == etag {
|
|
w.Header().Set("ETag", etag)
|
|
w.WriteHeader(http.StatusNotModified)
|
|
return
|
|
}
|
|
|
|
// Get file from storage
|
|
var reader io.ReadCloser
|
|
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()
|
|
|
|
// Read entire file for potential .kc repacking.
|
|
data, err := io.ReadAll(reader)
|
|
if err != nil {
|
|
s.logger.Error().Err(err).Msg("failed to read file")
|
|
writeError(w, http.StatusInternalServerError, "download_failed", "Failed to read file")
|
|
return
|
|
}
|
|
|
|
// Repack silo/ entries for .kc files with indexed metadata.
|
|
output := data
|
|
if meta != nil {
|
|
if hasSilo, chkErr := kc.HasSiloDir(data); chkErr == nil && hasSilo {
|
|
if !canSkipRepack(revision, meta) {
|
|
if packed, packErr := s.packKCFile(ctx, data, item, revision, meta); packErr != nil {
|
|
s.logger.Warn().Err(packErr).Str("part_number", partNumber).Msg("kc: packing failed, serving original")
|
|
} else {
|
|
output = packed
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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+"\"")
|
|
w.Header().Set("Content-Length", strconv.Itoa(len(output)))
|
|
w.Header().Set("ETag", etag)
|
|
w.Header().Set("Cache-Control", "private, must-revalidate")
|
|
|
|
w.Write(output)
|
|
}
|
|
|
|
// 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"),
|
|
}
|
|
}
|