package store import ( "encoding/json" "fmt" "mime" "os" "path/filepath" "sort" "strings" "time" "github.com/google/uuid" "printer.backend/internal/model" "printer.backend/internal/paths" ) // OrderStore persists orders as JSON files with images in per-order subdirectories. type OrderStore struct { dir string } // NewOrderStore creates a store under dir (default data/orders). func NewOrderStore(dir string) *OrderStore { if dir == "" { dir = paths.OrdersDir } return &OrderStore{dir: dir} } // List returns all orders sorted by creation time (newest first). func (s *OrderStore) List() ([]model.Order, error) { if err := ensureDir(s.dir); err != nil { return nil, err } entries, err := os.ReadDir(s.dir) if err != nil { return nil, fmt.Errorf("read orders dir: %w", err) } var out []model.Order for _, e := range entries { if e.IsDir() || filepath.Ext(e.Name()) != ".json" { continue } o, err := readOrderFile(filepath.Join(s.dir, e.Name())) if err != nil { return nil, err } out = append(out, o) } sort.Slice(out, func(i, j int) bool { return out[i].CreatedAt.After(out[j].CreatedAt) }) return out, nil } // Get returns an order by ID. func (s *OrderStore) Get(id string) (model.Order, error) { o, err := readOrderFile(s.metaPath(id)) if err != nil { if os.IsNotExist(err) { return model.Order{}, fmt.Errorf("order not found: %s", id) } return model.Order{}, err } return o, nil } func readOrderFile(path string) (model.Order, error) { var aux struct { model.Order RefLink string `json:"ref_link,omitempty"` } data, err := os.ReadFile(path) if err != nil { return model.Order{}, err } if err := json.Unmarshal(data, &aux); err != nil { return model.Order{}, err } o := aux.Order if o.Ref == "" && aux.RefLink != "" { o.Ref = aux.RefLink } if o.Images == nil { o.Images = []model.OrderImage{} } return o, nil } // Save writes a new order and assigns an ID when empty. func (s *OrderStore) Save(o model.Order) (model.Order, error) { if err := ensureDir(s.dir); err != nil { return model.Order{}, err } if o.ID == "" { o.ID = uuid.NewString() } if o.Images == nil { o.Images = []model.OrderImage{} } now := stampNew(&o.CreatedAt) o.CreatedAt = now o.UpdatedAt = now if err := writeJSON(s.metaPath(o.ID), o); err != nil { return model.Order{}, fmt.Errorf("write order: %w", err) } return o, nil } // Update replaces order metadata (preserves id, images, created_at). func (s *OrderStore) Update(id string, o model.Order) (model.Order, error) { existing, err := s.Get(id) if err != nil { return model.Order{}, err } o.ID = existing.ID o.Images = existing.Images o.CreatedAt = existing.CreatedAt o.UpdatedAt = time.Now().UTC() if err := writeJSON(s.metaPath(id), o); err != nil { return model.Order{}, fmt.Errorf("write order: %w", err) } return o, nil } // Delete removes an order and all of its images. func (s *OrderStore) Delete(id string) error { if _, err := s.Get(id); err != nil { return err } if err := os.Remove(s.metaPath(id)); err != nil && !os.IsNotExist(err) { return err } return os.RemoveAll(s.imagesDir(id)) } // AddImage stores image bytes and appends metadata to the order. func (s *OrderStore) AddImage(orderID, originalName string, data []byte, contentType string) (model.OrderImage, error) { if len(data) == 0 { return model.OrderImage{}, fmt.Errorf("empty image data") } order, err := s.Get(orderID) if err != nil { return model.OrderImage{}, err } ext := extensionForImage(originalName, contentType) imageID := uuid.NewString() filename := imageID + ext if err := ensureDir(s.imagesDir(orderID)); err != nil { return model.OrderImage{}, err } path := filepath.Join(s.imagesDir(orderID), filename) if err := os.WriteFile(path, data, 0o644); err != nil { return model.OrderImage{}, fmt.Errorf("write image: %w", err) } img := model.OrderImage{ ID: imageID, OriginalName: filepath.Base(originalName), ContentType: contentType, Filename: filename, CreatedAt: time.Now().UTC(), } order.Images = append(order.Images, img) order.UpdatedAt = time.Now().UTC() if err := writeJSON(s.metaPath(orderID), order); err != nil { _ = os.Remove(path) return model.OrderImage{}, fmt.Errorf("update order: %w", err) } return img, nil } // DeleteImage removes one image from an order. func (s *OrderStore) DeleteImage(orderID, imageID string) error { order, err := s.Get(orderID) if err != nil { return err } idx := -1 var filename string for i, img := range order.Images { if img.ID == imageID { idx = i filename = img.Filename break } } if idx < 0 { return fmt.Errorf("image not found: %s", imageID) } path := filepath.Join(s.imagesDir(orderID), filename) if err := os.Remove(path); err != nil && !os.IsNotExist(err) { return err } order.Images = append(order.Images[:idx], order.Images[idx+1:]...) order.UpdatedAt = time.Now().UTC() return writeJSON(s.metaPath(orderID), order) } // ImagePath returns the filesystem path for an order image. func (s *OrderStore) ImagePath(orderID, imageID string) (string, model.OrderImage, error) { order, err := s.Get(orderID) if err != nil { return "", model.OrderImage{}, err } for _, img := range order.Images { if img.ID == imageID { path := filepath.Join(s.imagesDir(orderID), img.Filename) if _, err := os.Stat(path); err != nil { return "", model.OrderImage{}, fmt.Errorf("image file missing: %w", err) } return path, img, nil } } return "", model.OrderImage{}, fmt.Errorf("image not found: %s", imageID) } func (s *OrderStore) metaPath(id string) string { return filepath.Join(s.dir, id+".json") } func (s *OrderStore) imagesDir(orderID string) string { return filepath.Join(s.dir, orderID) } func extensionForImage(originalName, contentType string) string { if ext := filepath.Ext(originalName); ext != "" { return strings.ToLower(ext) } if exts, _ := mime.ExtensionsByType(contentType); len(exts) > 0 { return exts[0] } switch contentType { case "image/jpeg": return ".jpg" case "image/png": return ".png" case "image/webp": return ".webp" case "image/gif": return ".gif" case "image/svg+xml": return ".svg" default: return ".bin" } }