refactor(orders): embed tracking on orders, drop shipments table
Some checks failed
Deploy / deploy (push) Has been cancelled

realized per-shipment table is kinda bad, switched to single embedded tracking blob. since im only
shipping from my house  no split is needed. adds tracking_status, tracking_status_details,
tracking_status_updated_at columns. reverse-backfills any historical shipments rows back onto orders
(idempotent, table-guarded). UpdateTracking rewritten to write to otder columns direcly
This commit is contained in:
2026-05-09 10:08:32 +02:00
parent f88fe1d8d3
commit c5f4e492c0
6 changed files with 108 additions and 151 deletions

View File

@@ -6,16 +6,15 @@ import (
"strconv"
"time"
"github.com/go-chi/chi/v5"
"github.com/stripe/stripe-go/v78"
"github.com/stripe/stripe-go/v78/refund"
"gorm.io/gorm"
"terminalShop/api/middleware"
"terminalShop/pkg/audit"
"terminalShop/pkg/database"
"terminalShop/pkg/models"
"terminalShop/pkg/utils"
"github.com/go-chi/chi/v5"
"github.com/stripe/stripe-go/v78"
"github.com/stripe/stripe-go/v78/refund"
)
type OrderHandler struct {
@@ -31,7 +30,7 @@ func (h *OrderHandler) GetOrders(w http.ResponseWriter, r *http.Request) {
userID := middleware.UserIDFromContext(r.Context())
var orders []models.Order
db.Where("user_id = ?", userID).Preload("Items").Preload("Shipments").Order("created_at desc").Find(&orders)
db.Where("user_id = ?", userID).Preload("Items").Order("created_at desc").Find(&orders)
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
"orders": orders,
@@ -141,27 +140,16 @@ func (h *OrderHandler) UpdateTracking(w http.ResponseWriter, r *http.Request) {
shippedAt = &now
}
shipment := models.Shipment{
OrderID: order.ID,
Carrier: req.Carrier,
TrackingNumber: req.TrackingNumber,
TrackingURL: req.TrackingURL,
Status: models.ShipmentStatusLabelCreated,
ShippedAt: shippedAt,
updates := map[string]any{
"carrier": req.Carrier,
"tracking_number": req.TrackingNumber,
"tracking_url": req.TrackingURL,
"shipped_at": shippedAt,
}
err = db.Transaction(func(tx *gorm.DB) error {
if err := tx.Create(&shipment).Error; err != nil {
return err
}
if order.Status == models.OrderStatusPaid {
if err := tx.Model(&order).Update("status", models.OrderStatusShipped).Error; err != nil {
return err
}
}
return nil
})
if err != nil {
if order.Status == models.OrderStatusPaid {
updates["status"] = models.OrderStatusShipped
}
if err := db.Model(&order).Updates(updates).Error; err != nil {
utils.RespondError(w, http.StatusInternalServerError, "DATABASE_ERROR", "failed to record shipment", nil)
return
}
@@ -170,7 +158,6 @@ func (h *OrderHandler) UpdateTracking(w http.ResponseWriter, r *http.Request) {
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
"order_id": order.ID,
"shipment_id": shipment.ID,
"carrier": req.Carrier,
"tracking_number": req.TrackingNumber,
"status": order.Status,

View File

@@ -38,7 +38,7 @@ func (h *ViewHandler) GetViewInit(w http.ResponseWriter, r *http.Request) {
db.Where("user_id = ?", userID).Find(&cards)
var orders []models.Order
db.Where("user_id = ?", userID).Preload("Items").Preload("Shipments").Order("created_at desc").Find(&orders)
db.Where("user_id = ?", userID).Preload("Items").Order("created_at DESC").Find(&orders)
var cartItems []models.CartItem
if cart.ID != 0 {
db.Where("cart_id = ? AND quantity > 0", cart.ID).Preload("Coffee").Find(&cartItems)

View File

@@ -3,8 +3,6 @@ package database
import (
"fmt"
"log"
"time"
"terminalShop/pkg/models"
"gorm.io/gorm"
@@ -25,50 +23,31 @@ func Migrate(db *gorm.DB) error {
&models.Order{}, // Completed purchases
&models.OrderItem{}, // Line items within orders
&models.Address{}, // Saved addresses
&models.Shipment{},
); err != nil {
return fmt.Errorf("migration failed: %w", err)
}
if err := backfillShipments(db); err != nil {
return fmt.Errorf("shipments backfill failed: %w", err)
if err := backfillTrackingFromShipments(db); err != nil {
return fmt.Errorf("backfill tracking failed: %w", err)
}
log.Println("Database migrations completed successfully")
return nil
}
func backfillShipments(db *gorm.DB) error {
if !db.Migrator().HasColumn(&models.Order{}, "carrier") {
func backfillTrackingFromShipments(db *gorm.DB) error {
if !db.Migrator().HasTable("shipments") {
return nil
}
type legacy struct {
ID uint
Carrier string
TrackingNumber string
TrackingURL string
ShippedAt *time.Time
}
var rows []legacy
if err := db.Raw(`SELECT o.id, o.carrier, o.tracking_number, o.tracking_url, o.shipped_at
FROM orders o
WHERE o.carrier <> ''
AND NOT EXISTS(SELECT 1 FROM shipments s WHERE s.order_id = o.id)
`).Scan(&rows).Error; err != nil {
return err
}
for _, r := range rows {
s := models.Shipment{
OrderID: r.ID,
Carrier: r.Carrier,
TrackingNumber: r.TrackingNumber,
TrackingURL: r.TrackingURL,
Status: models.ShipmentStatusInTransit,
ShippedAt: r.ShippedAt,
}
if err := db.Create(&s).Error; err != nil {
return fmt.Errorf("backfill order %d: %w", r.ID, err)
}
log.Printf("backfilled shipment for order %d", r.ID)
}
return nil
return db.Exec(`
UPDATE orders SET
carrier = COALESCE(NULLIF(orders.carrier, ''), s.carrier),
tracking_number = COALESCE(NULLIF(orders.tracking_number, ''), s.tracking_number),
tracking_url = COALESCE(NULLIF(orders.tracking_url, ''), s.tracking_url),
shipped_at = COALESCE(orders.shipped_at, s.shipped_at)
FROM (SELECT order_id,
carrier, tracking_number, tracking_url, shipped_at
FROM shipments
WHERE deleted_at IS NULL) s
WHERE orders.id = s.order_id
`).Error
}

View File

@@ -10,42 +10,62 @@ import (
type OrderStatus string
const (
OrderStatusPending OrderStatus = "pending"
OrderStatusPaid OrderStatus = "paid"
OrderStatusShipped OrderStatus = "shipped"
// OrderStatusDelivered OrderStatus = "delivered"
// OrderStatusCancelled OrderStatus = "cancelled"
OrderStatusRefunded OrderStatus = "refunded"
OrderStatusFailed OrderStatus = "failed"
OrderStatusPending OrderStatus = "pending"
OrderStatusPaid OrderStatus = "paid"
OrderStatusShipped OrderStatus = "shipped"
OrderStatusDelivered OrderStatus = "delivered"
OrderStatusCancelled OrderStatus = "cancelled"
OrderStatusRefunded OrderStatus = "refunded"
OrderStatusFailed OrderStatus = "failed"
)
type TrackingStatus string
const (
TrackingStatusUnknown TrackingStatus = ""
TrackingStatusPreTransit TrackingStatus = "pre_transit"
TrackingStatusTransit TrackingStatus = "transit"
TrackingStatusDelivered TrackingStatus = "delivered"
TrackingStatusReturned TrackingStatus = "returned"
TrackingStatusFailure TrackingStatus = "failure"
)
// Order represents a completed purchase.
type Order struct {
ID uint `gorm:"primaryKey" json:"id"`
UserID uint `gorm:"not null;index" json:"user_id"`
CardID uint `gorm:"not null" json:"card_id"`
StripePaymentID string `gorm:"size:255" json:"-"` // Stripe PaymentIntent ID
Status OrderStatus `gorm:"size:50;not null;default:'pending'" json:"status"`
Subtotal int `gorm:"not null" json:"subtotal"` // Amount in cents
ShippingCost int `gorm:"default:0" json:"shipping_cost"` // Amount in cents
Total int `gorm:"not null" json:"total"` // Amount in cents
ShippingName string `gorm:"size:255" json:"shipping_name"` // The name of the person the shipment is tied to
ShippingStreet string `gorm:"size:255" json:"shipping_street"` // The street address the shipment is tied to
ShippingStreet2 string `gorm:"size:255" json:"shipping_street2"` // The alternate street address the shipment is going to if first is valid for whatever reason
ShippingCity string `gorm:"size:255" json:"shipping_city"` // The city the shipment is going to
ShippingState string `gorm:"size:50" json:"shipping_state"` // The state the shipment is tied to
ShippingZip string `gorm:"size:20" json:"shipping_zip"` // The zip code the order is tied to
ShippingCountry string `gorm:"size:2" json:"shipping_country"` // The country the shipment is tied to
ShippingPhone string `gorm:"size:20" json:"shipping_phone"` // Phone number the shipment is tied to
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` // GORM soft-delete sentinel
ID uint `gorm:"primaryKey" json:"id"`
UserID uint `gorm:"not null;index" json:"user_id"`
CardID uint `gorm:"not null" json:"card_id"`
StripePaymentID string `gorm:"size:255" json:"-"` // Stripe PaymentIntent ID
Status OrderStatus `gorm:"size:50;not null;default:'pending'" json:"status"`
Subtotal int `gorm:"not null" json:"subtotal"` // Amount in cents
ShippingCost int `gorm:"default:0" json:"shipping_cost"` // Amount in cents
Total int `gorm:"not null" json:"total"` // Amount in cents
ShippingName string `gorm:"size:255" json:"shipping_name"` // The name of the person the shipment is tied to
ShippingStreet string `gorm:"size:255" json:"shipping_street"` // The street address the shipment is tied to
ShippingStreet2 string `gorm:"size:255" json:"shipping_street2"` // The alternate street address the shipment is going to if first is valid for whatever reason
ShippingCity string `gorm:"size:255" json:"shipping_city"` // The city the shipment is going to
ShippingState string `gorm:"size:50" json:"shipping_state"` // The state the shipment is tied to
ShippingZip string `gorm:"size:20" json:"shipping_zip"` // The zip code the order is tied to
ShippingCountry string `gorm:"size:2" json:"shipping_country"` // The country the shipment is tied to
ShippingPhone string `gorm:"size:20" json:"shipping_phone"` // Phone number the shipment is tied to
// Shipment / tracking - single embedded blob (since its just one warehouse (my house))
Carrier string `gorm:"size:100" json:"carrier"`
TrackingNumber string `gorm:"size:255;index" json:"tracking_number"`
TrackingURL string `gorm:"size:512" json:"tracking_url"`
ShippedAt *time.Time `json:"shipped_at"`
TrackingStatus TrackingStatus `gorm:"size:50" json:"tracking_status"`
TrackingStatusDetails string `gorm:"size:512" json:"tracking_status_details"`
TrackingStatusUpdatedAt *time.Time `json:"tracking_status_updated_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` // GORM soft-delete sentinel
// Associations
User User `gorm:"foreignKey:UserID" json:"-"`
Card Card `gorm:"foreignKey:CardID" json:"-"`
Items []OrderItem `gorm:"foreignKey:OrderID" json:"items"`
Shipments []Shipment `gorm:"foreignKey:OrderID" json:"shipments,omitempty"`
User User `gorm:"foreignKey:UserID" json:"-"`
Card Card `gorm:"foreignKey:CardID" json:"-"`
Items []OrderItem `gorm:"foreignKey:OrderID" json:"items"`
}
// TableName specifies the table name for the Order model.

View File

@@ -1,43 +0,0 @@
package models
import (
"time"
"gorm.io/gorm"
)
// ShipmentStatus represents the states a shipment can be in
type ShipmentStatus string
const (
ShipmentStatusPending ShipmentStatus = "pending"
ShipmentStatusLabelCreated ShipmentStatus = "label_created"
ShipmentStatusInTransit ShipmentStatus = "in_transit"
ShipmentStatusDelivered ShipmentStatus = "delivered"
ShipmentStatusException ShipmentStatus = "exception"
)
// Shipment represents a single physical dispatch attached to an order
// Most orders have one, the scheme permits multiple (split shipments)
type Shipment struct {
ID uint `gorm:"primaryKey" json:"id"`
OrderID uint `gorm:"not null;index" json:"order_id"`
Carrier string `gorm:"size:100" json:"carrier"`
TrackingNumber string `gorm:"size:255" json:"tracking_number"`
TrackingURL string `gorm:"size:512" json:"tracking_url"`
Status ShipmentStatus `gorm:"size:50, not null; default:'pending'" json:"status"`
ShippoTransactionID *string `gorm:"size:255;uniqueIndex" json:"shippo_transaction_id,omitempty"`
LabelPDFURL string `gorm:"size:255" json:"label_pdf_url,omitempty,omitempty"`
CostCents int `gorm:"default:0" json:"cost_cents"`
ShippedAt *time.Time `json:"shipped_at"`
DeliveredAt *time.Time `json:"delivered_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
Order Order `gorm:"foreignKey:OrderID" json:"-"`
}
func (Shipment) TableName() string {
return "shipments"
}

View File

@@ -61,8 +61,6 @@ func (m Model) buildOrderCard(order models.Order, boxWidth int, isSelected bool)
dimStyle = m.theme.TextAccent()
}
statusStyle := m.theme.TextHighlight().Bold(true)
// Line 1: "Order #N" left, "$X.XX" right
orderLabel := nameStyle.Render(fmt.Sprintf("Order #%d", order.ID))
total := nameStyle.Render(fmt.Sprintf("$%.2f", float64(order.Total)/100.0))
@@ -78,7 +76,7 @@ func (m Model) buildOrderCard(order models.Order, boxWidth int, isSelected bool)
// Line 2: date + status
date := dimStyle.Render(order.CreatedAt.Format("Jan 02 2006"))
status := statusStyle.Render(strings.ToUpper(string(order.Status)))
status := m.statusStyle(order.Status).Bold(true).Render(strings.ToUpper(string(order.Status)))
line2 := date + " " + status
boxContent := line1 + "\n" + line2
@@ -104,7 +102,7 @@ func (m Model) buildOrderDetailView(order models.Order, _ int) string {
// Status and date
b.WriteString(labelStyle.Render("Status: "))
b.WriteString(valueStyle.Render(strings.ToUpper(string(order.Status))))
b.WriteString(m.statusStyle(order.Status).Render(strings.ToUpper(string(order.Status))))
b.WriteString("\n")
b.WriteString(labelStyle.Render("Date: "))
b.WriteString(valueStyle.Render(order.CreatedAt.Format("Jan 02 2006 3:04 PM")))
@@ -136,21 +134,24 @@ func (m Model) buildOrderDetailView(order models.Order, _ int) string {
b.WriteString("\n\n")
// Shipping (only when at least one shipment exists)
if len(order.Shipments) > 0 {
s := order.Shipments[0]
if order.Carrier != "" {
b.WriteString(labelStyle.Render("Shipping"))
b.WriteString("\n")
b.WriteString(valueStyle.Render(fmt.Sprintf(" %-10s %s", "Carrier", s.Carrier)))
b.WriteString(valueStyle.Render(fmt.Sprintf(" %-10s %s", "Carrier", order.Carrier)))
b.WriteString("\n")
b.WriteString(valueStyle.Render(fmt.Sprintf(" %-10s %s", "Tracking", s.TrackingNumber)))
b.WriteString(valueStyle.Render(fmt.Sprintf(" %-10s %s", "Tracking", order.TrackingNumber)))
b.WriteString("\n")
if s.ShippedAt != nil {
b.WriteString(valueStyle.Render(fmt.Sprintf(" %-10s %s",
"Shipped", s.ShippedAt.Format("Jan 02 2006"))))
if order.TrackingStatus != "" {
b.WriteString(valueStyle.Render(fmt.Sprintf(" %-10s %s", "Status", string(order.TrackingStatus))))
b.WriteString("\n")
}
if s.TrackingURL != "" {
b.WriteString(dimStyle.Render(" " + s.TrackingURL))
if order.ShippedAt != nil {
b.WriteString(valueStyle.Render(fmt.Sprintf(" %-10s %s",
"Shipped", order.ShippedAt.Format("Jan 02 2006"))))
b.WriteString("\n")
}
if order.TrackingURL != "" {
b.WriteString(dimStyle.Render(" " + order.TrackingURL))
b.WriteString("\n")
}
}
@@ -181,3 +182,16 @@ func (m Model) buildOrderDetailView(order models.Order, _ int) string {
return b.String()
}
func (m Model) statusStyle(s models.OrderStatus) lipgloss.Style {
switch s {
case models.OrderStatusDelivered:
return m.theme.TextSuccess()
case models.OrderStatusShipped:
return m.theme.TextBrand()
case models.OrderStatusFailed, models.OrderStatusCancelled, models.OrderStatusRefunded:
return m.theme.TextError()
}
return m.theme.TextHighlight()
}