Added Model Orders and Picture Upload

This commit is contained in:
simon 2026-05-26 17:47:54 +02:00
parent aeeefc89c1
commit 9467ff5f6b
6 changed files with 856 additions and 6 deletions

21
internal/model/order.go Normal file
View File

@ -0,0 +1,21 @@
package model
import "time"
// OrderImage is one uploaded image belonging to an order.
type OrderImage struct {
ID string `json:"id"`
OriginalName string `json:"original_name,omitempty"`
ContentType string `json:"content_type,omitempty"`
Filename string `json:"filename"`
CreatedAt time.Time `json:"created_at"`
}
// Order groups an arbitrary number of customer images for printing.
type Order struct {
ID string `json:"id"`
Name string `json:"name,omitempty"`
Images []OrderImage `json:"images"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}

View File

@ -2,8 +2,9 @@ package paths
// Runtime data directories (relative to process working directory). // Runtime data directories (relative to process working directory).
const ( const (
DataDir = "data" DataDir = "data"
PlatesDir = "data/plates" PlatesDir = "data/plates"
SVGTemplateDir = "data/svg_template" SVGTemplateDir = "data/svg_template"
ConfigurationsDir = "data/configurations" ConfigurationsDir = "data/configurations"
OrdersDir = "data/orders"
) )

View File

@ -245,6 +245,92 @@
padding: 2rem 0; padding: 2rem 0;
text-align: center; text-align: center;
} }
.order-images {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 1rem;
margin-top: 1.25rem;
}
.order-image-card {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
display: flex;
flex-direction: column;
}
.order-image-thumb {
display: block;
width: 100%;
padding: 0;
border: none;
background: #fff;
cursor: zoom-in;
}
.order-image-thumb img {
width: 100%;
aspect-ratio: 1;
object-fit: contain;
display: block;
background: #fff;
}
.order-lightbox {
position: fixed;
inset: 0;
z-index: 1000;
background: rgba(0, 0, 0, 0.88);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 2rem;
cursor: zoom-out;
}
.order-lightbox img {
max-width: min(96vw, 1200px);
max-height: 82vh;
object-fit: contain;
background: #fff;
border-radius: 6px;
}
.order-lightbox-caption {
margin-top: 1rem;
color: var(--text);
font-size: 0.9rem;
text-align: center;
max-width: 90vw;
}
.order-lightbox-hint {
margin-top: 0.5rem;
color: var(--muted);
font-size: 0.8rem;
}
.order-image-meta {
padding: 0.5rem 0.65rem;
font-size: 0.75rem;
color: var(--muted);
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.order-image-actions {
padding: 0 0.65rem 0.65rem;
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.order-image-actions button {
width: 100%;
padding: 0.35rem 0.5rem;
font-size: 0.78rem;
}
[x-cloak] { display: none !important; }
input[type="file"].file-input {
margin-top: 0.75rem;
padding: 0.5rem;
font-size: 0.82rem;
}
@media (max-width: 720px) { @media (max-width: 720px) {
.app { flex-direction: column; } .app { flex-direction: column; }
.sidebar { .sidebar {
@ -334,6 +420,29 @@
@click="newConfiguration()">+ Neu</button> @click="newConfiguration()">+ Neu</button>
</div> </div>
</div> </div>
<div class="nav-section">
<button type="button" class="nav-section-header"
:class="{ active: section === 'orders' }"
@click="openSection('orders')">
Orders
<span class="nav-chevron" :class="{ open: expanded.orders }"></span>
</button>
<div class="nav-children" x-show="expanded.orders">
<template x-if="orders.length === 0 && !ordersLoading">
<p class="nav-empty">Keine Orders</p>
</template>
<template x-for="o in orders" :key="o.id">
<button type="button" class="nav-item"
:class="{ selected: section === 'orders' && orderEditingId === o.id }"
@click="selectOrder(o)"
x-text="sidebarOrderLabel(o)"></button>
</template>
<button type="button" class="nav-item add"
:class="{ selected: section === 'orders' && !orderEditingId }"
@click="newOrder()">+ Neu</button>
</div>
</div>
</nav> </nav>
<div class="sidebar-footer"> <div class="sidebar-footer">
@ -517,7 +626,78 @@
<p class="muted" x-show="layoutLoading">Berechne Layout…</p> <p class="muted" x-show="layoutLoading">Berechne Layout…</p>
</div> </div>
</div> </div>
<!-- Orders -->
<div x-show="section === 'orders'">
<div class="main-header">
<h2 x-text="orderEditingId ? 'Order bearbeiten' : 'Neue Order'"></h2>
<p class="muted" x-show="!orderEditingId">Order anlegen, danach Bilder hochladen.</p>
<p class="muted" x-show="orderEditingId">Beliebig viele Bilder pro Order (PNG, JPEG, WebP, GIF).</p>
</div>
<div class="panel">
<form @submit.prevent="saveOrder()">
<div class="form-grid">
<div>
<label>Name (optional)</label>
<input type="text" x-model="orderForm.name" placeholder="z.B. Kunde Müller">
</div>
</div>
<div class="btn-row">
<button type="submit" :disabled="orderSaving">
<span x-text="orderSaving ? 'Speichere…' : (orderEditingId ? 'Speichern' : 'Anlegen')"></span>
</button>
<button type="button" class="danger" x-show="orderEditingId" @click="deleteOrder(orderEditingId)">Löschen</button>
</div>
<p class="msg-err" x-show="orderError" x-text="orderError"></p>
<p class="msg-ok" x-show="orderSuccess" x-text="orderSuccess"></p>
</form>
<div x-show="orderEditingId" style="margin-top: 1.5rem;">
<label>Bilder hochladen</label>
<input type="file" class="file-input" accept="image/*" multiple
:disabled="orderImageUploading || orderLoading"
@change="uploadOrderImages($event)">
<p class="muted" x-show="orderImageUploading">Lade hoch…</p>
<p class="muted" x-show="orderLoading">Lade Bilder…</p>
<div class="order-images" x-show="orderImages.length > 0">
<template x-for="img in orderImages" :key="img.id">
<div class="order-image-card">
<button type="button" class="order-image-thumb"
@click="openOrderImage(img)"
:title="(img.original_name || img.filename) + ' — Klicken für Vollbild'">
<img :src="orderImageUrl(orderEditingId, img.id)"
:alt="img.original_name || 'Bild'"
loading="lazy"
@error="onOrderImageError($event, img)">
</button>
<p class="order-image-meta" x-text="img.original_name || img.filename"></p>
<div class="order-image-actions">
<button type="button" class="secondary" @click="openOrderImage(img)">Vergrößern</button>
<button type="button" class="danger" @click="deleteOrderImage(img.id)">Entfernen</button>
</div>
</div>
</template>
</div>
<p class="muted" x-show="orderImages.length === 0 && !orderImageUploading && !orderLoading" style="margin-top: 1rem;">
Noch keine Bilder — Dateien oben auswählen.
</p>
</div>
</div>
</div>
</main> </main>
<div class="order-lightbox" x-show="orderLightbox" x-cloak
@click="closeOrderLightbox()"
@keydown.escape.window="closeOrderLightbox()">
<template x-if="orderLightbox">
<div @click.stop style="display: contents;">
<img :src="orderImageUrl(orderLightbox.orderId, orderLightbox.imageId)" :alt="orderLightbox.label">
<p class="order-lightbox-caption" x-text="orderLightbox.label"></p>
<p class="order-lightbox-hint">Klicken oder Esc zum Schließen</p>
</div>
</template>
</div>
</div> </div>
<script> <script>
@ -527,7 +707,7 @@
apiOk: null, apiOk: null,
section: 'plates', section: 'plates',
expanded: { plates: true, items: false, configurations: false }, expanded: { plates: true, items: false, configurations: false, orders: false },
plates: [], plates: [],
platesLoading: false, platesLoading: false,
@ -577,6 +757,19 @@
pdfGenerating: false, pdfGenerating: false,
_layoutTimer: null, _layoutTimer: null,
orders: [],
ordersLoading: false,
orderSaving: false,
orderImageUploading: false,
orderError: '',
orderSuccess: '',
orderEditingId: null,
orderImages: [],
orderForm: { name: '' },
orderLoading: false,
orderImageVersion: 0,
orderLightbox: null,
openSection(name) { openSection(name) {
if (this.section === name) { if (this.section === name) {
this.expanded[name] = !this.expanded[name]; this.expanded[name] = !this.expanded[name];
@ -622,6 +815,18 @@
this.cancelConfigEdit(); this.cancelConfigEdit();
}, },
async selectOrder(o) {
this.section = 'orders';
this.expanded.orders = true;
await this.editOrder(o);
},
newOrder() {
this.section = 'orders';
this.expanded.orders = true;
this.cancelOrderEdit();
},
sidebarPlateLabel(p) { sidebarPlateLabel(p) {
const n = p.name || 'Platte'; const n = p.name || 'Platte';
return n + ' · ' + this.fmtSize(p.width_mm, p.height_mm); return n + ' · ' + this.fmtSize(p.width_mm, p.height_mm);
@ -631,6 +836,12 @@
return c.name || ('Konfiguration ' + c.id.slice(0, 8)); return c.name || ('Konfiguration ' + c.id.slice(0, 8));
}, },
sidebarOrderLabel(o) {
const n = o.name || ('Order ' + o.id.slice(0, 8));
const count = (o.images || []).length;
return n + (count ? ' · ' + count + ' Bild' + (count === 1 ? '' : 'er') : '');
},
apiUrl(path) { apiUrl(path) {
return `${this.apiBase.replace(/\/$/, '')}${path}`; return `${this.apiBase.replace(/\/$/, '')}${path}`;
}, },
@ -639,6 +850,31 @@
return `${this.apiUrl('/items/' + id + '/svg')}?t=${Date.now()}`; return `${this.apiUrl('/items/' + id + '/svg')}?t=${Date.now()}`;
}, },
orderImageUrl(orderId, imageId) {
return `${this.apiUrl('/orders/' + orderId + '/images/' + imageId)}?v=${this.orderImageVersion}`;
},
openOrderImage(img) {
if (!this.orderEditingId || !img?.id) return;
this.orderLightbox = {
orderId: this.orderEditingId,
imageId: img.id,
label: img.original_name || img.filename || 'Bild',
};
},
closeOrderLightbox() {
this.orderLightbox = null;
},
onOrderImageError(event, img) {
const el = event.target;
if (!el || el.dataset.retried) return;
el.dataset.retried = '1';
this.orderImageVersion++;
el.src = this.orderImageUrl(this.orderEditingId, img.id);
},
async init() { async init() {
try { try {
const res = await fetch('/config.json'); const res = await fetch('/config.json');
@ -654,7 +890,12 @@
async onApiBaseChange() { async onApiBaseChange() {
await this.checkAPI(); await this.checkAPI();
if (this.apiOk) { if (this.apiOk) {
await Promise.all([this.loadPlates(), this.loadItems(), this.loadConfigurations()]); await Promise.all([
this.loadPlates(),
this.loadItems(),
this.loadConfigurations(),
this.loadOrders(),
]);
} }
}, },
@ -1072,6 +1313,160 @@
} }
}, },
async loadOrders() {
this.ordersLoading = true;
try {
this.orders = await this.apiJSON('GET', '/orders') || [];
} catch (e) {
this.orderError = String(e.message || e);
} finally {
this.ordersLoading = false;
}
},
async editOrder(o) {
this.orderEditingId = o.id;
this.orderForm = { name: o.name || '' };
this.orderImages = o.images ? [...o.images] : [];
this.orderError = '';
this.orderSuccess = '';
this.orderLoading = true;
try {
const fresh = await this.apiJSON('GET', '/orders/' + o.id);
this.orderForm = { name: fresh.name || '' };
this.orderImages = fresh.images ? [...fresh.images] : [];
this.orderImageVersion++;
} catch (e) {
this.orderError = String(e.message || e);
} finally {
this.orderLoading = false;
}
},
cancelOrderEdit() {
this.orderEditingId = null;
this.orderForm = { name: '' };
this.orderImages = [];
this.orderLightbox = null;
this.orderError = '';
this.orderSuccess = '';
},
async saveOrder() {
this.orderSaving = true;
this.orderError = '';
this.orderSuccess = '';
const body = { name: this.orderForm.name };
try {
let saved;
if (this.orderEditingId) {
saved = await this.apiJSON('PUT', '/orders/' + this.orderEditingId, body);
this.orderSuccess = 'Order aktualisiert.';
} else {
saved = await this.apiJSON('POST', '/orders', body);
this.orderSuccess = 'Order angelegt.';
}
const keepId = saved?.id || this.orderEditingId;
await this.loadOrders();
if (keepId) {
const o = this.orders.find(x => x.id === keepId);
if (o) await this.editOrder(o);
else this.cancelOrderEdit();
} else {
this.cancelOrderEdit();
}
} catch (e) {
this.orderError = String(e.message || e);
} finally {
this.orderSaving = false;
}
},
async uploadOrderImages(event) {
const files = event.target?.files;
if (!files?.length || !this.orderEditingId) return;
this.orderImageUploading = true;
this.orderError = '';
this.orderSuccess = '';
const fd = new FormData();
for (const f of files) {
fd.append('images', f);
}
try {
const res = await fetch(this.apiUrl('/orders/' + this.orderEditingId + '/images'), {
method: 'POST',
body: fd,
});
const text = await res.text();
if (!res.ok) {
let msg = res.statusText;
try {
const err = JSON.parse(text);
if (err.error) msg = err.error;
} catch {
if (text) msg = text;
}
throw new Error(msg);
}
const result = text ? JSON.parse(text) : null;
this.orderImageVersion++;
if (result?.order) {
await this.editOrder(result.order);
} else {
await this.loadOrders();
const o = this.orders.find(x => x.id === this.orderEditingId);
if (o) await this.editOrder(o);
}
const n = (result?.added || []).length;
this.orderSuccess = n === 1 ? '1 Bild hochgeladen.' : n + ' Bilder hochgeladen.';
} catch (e) {
this.orderError = String(e.message || e);
} finally {
this.orderImageUploading = false;
if (event.target) event.target.value = '';
}
},
async deleteOrderImage(imageId) {
if (!this.orderEditingId) return;
if (!confirm('Bild wirklich entfernen?')) return;
this.orderError = '';
try {
const res = await fetch(
this.apiUrl('/orders/' + this.orderEditingId + '/images/' + imageId),
{ method: 'DELETE' },
);
if (!res.ok) {
const text = await res.text();
throw new Error(text || res.statusText);
}
this.orderImageVersion++;
await this.loadOrders();
const o = this.orders.find(x => x.id === this.orderEditingId);
if (o) await this.editOrder(o);
this.orderSuccess = 'Bild entfernt.';
} catch (e) {
this.orderError = String(e.message || e);
}
},
async deleteOrder(id) {
if (!confirm('Order inkl. aller Bilder wirklich löschen?')) return;
this.orderError = '';
try {
const res = await fetch(this.apiUrl('/orders/' + id), { method: 'DELETE' });
if (!res.ok) {
const text = await res.text();
throw new Error(text || res.statusText);
}
await this.loadOrders();
if (this.orderEditingId === id) this.cancelOrderEdit();
} catch (e) {
this.orderError = String(e.message || e);
}
},
refreshLayoutPreview() { refreshLayoutPreview() {
clearTimeout(this._layoutTimer); clearTimeout(this._layoutTimer);
this._layoutTimer = setTimeout(() => this.fetchLayoutPreview(), 200); this._layoutTimer = setTimeout(() => this.fetchLayoutPreview(), 200);

View File

@ -16,6 +16,7 @@ func NewHandler() http.Handler {
plates := store.NewPlateStore("") plates := store.NewPlateStore("")
items := store.NewItemStore("") items := store.NewItemStore("")
configs := store.NewConfigurationStore("") configs := store.NewConfigurationStore("")
orders := store.NewOrderStore("")
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("GET /health", health) mux.HandleFunc("GET /health", health)
@ -30,6 +31,7 @@ func NewHandler() http.Handler {
mux.HandleFunc("DELETE /items/{id}", deleteItem(items)) mux.HandleFunc("DELETE /items/{id}", deleteItem(items))
mux.HandleFunc("GET /items/{id}/svg", serveItemSVG(items)) mux.HandleFunc("GET /items/{id}/svg", serveItemSVG(items))
registerConfigurationRoutes(mux, configs, plates, items) registerConfigurationRoutes(mux, configs, plates, items)
registerOrderRoutes(mux, orders)
return withCORS(mux) return withCORS(mux)
} }

View File

@ -0,0 +1,213 @@
package api
import (
"encoding/json"
"errors"
"io"
"mime"
"mime/multipart"
"net/http"
"os"
"path/filepath"
"printer.backend/internal/model"
"printer.backend/internal/store"
)
func registerOrderRoutes(mux *http.ServeMux, orders *store.OrderStore) {
mux.HandleFunc("GET /orders", listOrders(orders))
mux.HandleFunc("POST /orders", saveOrder(orders))
mux.HandleFunc("GET /orders/{id}", getOrder(orders))
mux.HandleFunc("PUT /orders/{id}", updateOrder(orders))
mux.HandleFunc("DELETE /orders/{id}", deleteOrder(orders))
mux.HandleFunc("POST /orders/{id}/images", addOrderImages(orders))
mux.HandleFunc("DELETE /orders/{id}/images/{imageId}", deleteOrderImage(orders))
mux.HandleFunc("GET /orders/{id}/images/{imageId}", serveOrderImage(orders))
}
func listOrders(s *store.OrderStore) http.HandlerFunc {
return func(w http.ResponseWriter, _ *http.Request) {
list, err := s.List()
if err != nil {
writeError(w, http.StatusInternalServerError, err)
return
}
if list == nil {
list = []model.Order{}
}
for i := range list {
if list[i].Images == nil {
list[i].Images = []model.OrderImage{}
}
}
writeJSON(w, http.StatusOK, list)
}
}
type saveOrderRequest struct {
Name string `json:"name"`
}
func saveOrder(s *store.OrderStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req saveOrderRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
saved, err := s.Save(model.Order{Name: req.Name})
if err != nil {
writeError(w, http.StatusInternalServerError, err)
return
}
writeJSON(w, http.StatusCreated, saved)
}
}
func getOrder(s *store.OrderStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
o, err := s.Get(id)
if err != nil {
writeError(w, http.StatusNotFound, err)
return
}
writeJSON(w, http.StatusOK, o)
}
}
func updateOrder(s *store.OrderStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
var req saveOrderRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
saved, err := s.Update(id, model.Order{Name: req.Name})
if err != nil {
writeError(w, http.StatusNotFound, err)
return
}
writeJSON(w, http.StatusOK, saved)
}
}
func deleteOrder(s *store.OrderStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if err := s.Delete(id); err != nil {
writeError(w, http.StatusNotFound, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
}
func addOrderImages(s *store.OrderStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orderID := r.PathValue("id")
if _, err := s.Get(orderID); err != nil {
writeError(w, http.StatusNotFound, err)
return
}
if err := r.ParseMultipartForm(32 << 20); err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
var files []*multipart.FileHeader
if r.MultipartForm != nil && len(r.MultipartForm.File["images"]) > 0 {
files = r.MultipartForm.File["images"]
} else if _, fh, err := r.FormFile("image"); err == nil {
files = []*multipart.FileHeader{fh}
} else {
writeError(w, http.StatusBadRequest, errors.New("image or images field required"))
return
}
added := make([]model.OrderImage, 0, len(files))
for _, fh := range files {
data, contentType, err := readUpload(fh)
if err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
img, err := s.AddImage(orderID, fh.Filename, data, contentType)
if err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
added = append(added, img)
}
order, err := s.Get(orderID)
if err != nil {
writeError(w, http.StatusInternalServerError, err)
return
}
writeJSON(w, http.StatusCreated, map[string]any{
"added": added,
"order": order,
})
}
}
func readUpload(fh *multipart.FileHeader) ([]byte, string, error) {
rc, err := fh.Open()
if err != nil {
return nil, "", err
}
defer rc.Close()
const maxImageSize = 32 << 20
data, err := io.ReadAll(io.LimitReader(rc, maxImageSize+1))
if err != nil {
return nil, "", err
}
if len(data) > maxImageSize {
return nil, "", errors.New("image too large")
}
contentType := http.DetectContentType(data)
return data, contentType, nil
}
func deleteOrderImage(s *store.OrderStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orderID := r.PathValue("id")
imageID := r.PathValue("imageId")
if err := s.DeleteImage(orderID, imageID); err != nil {
writeError(w, http.StatusNotFound, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
}
func serveOrderImage(s *store.OrderStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orderID := r.PathValue("id")
imageID := r.PathValue("imageId")
path, img, err := s.ImagePath(orderID, imageID)
if err != nil {
writeError(w, http.StatusNotFound, err)
return
}
data, err := os.ReadFile(path)
if err != nil {
writeError(w, http.StatusInternalServerError, err)
return
}
contentType := img.ContentType
if contentType == "" {
contentType = mime.TypeByExtension(filepath.Ext(path))
}
if contentType == "" || contentType == "application/octet-stream" {
contentType = http.DetectContentType(data)
}
w.Header().Set("Content-Type", contentType)
w.Header().Set("Cache-Control", "no-cache")
_, _ = w.Write(data)
}
}

218
internal/store/order.go Normal file
View File

@ -0,0 +1,218 @@
package store
import (
"fmt"
"mime"
"os"
"path/filepath"
"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) {
return listFromDir(s.dir,
func(name string) bool { return filepath.Ext(name) == ".json" },
"orders dir",
func(o model.Order) time.Time { return o.CreatedAt },
)
}
// Get returns an order by ID.
func (s *OrderStore) Get(id string) (model.Order, error) {
path := s.metaPath(id)
o, err := readJSON[model.Order](path)
if err != nil {
if os.IsNotExist(err) {
return model.Order{}, fmt.Errorf("order not found: %s", id)
}
return model.Order{}, err
}
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"
}
}