feat: extract shipments to dedicated table

This commit is contained in:
2026-05-08 14:54:17 +02:00
parent 9b45fb5138
commit add6e573b3
5 changed files with 137 additions and 42 deletions

View File

@@ -9,6 +9,7 @@ import (
"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"
@@ -30,7 +31,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").Order("created_at desc").Find(&orders)
db.Where("user_id = ?", userID).Preload("Items").Preload("Shipments").Order("created_at desc").Find(&orders)
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
"orders": orders,
@@ -140,18 +141,28 @@ func (h *OrderHandler) UpdateTracking(w http.ResponseWriter, r *http.Request) {
shippedAt = &now
}
updates := map[string]interface{}{
"carrier": req.Carrier,
"tracking_number": req.TrackingNumber,
"tracking_url": req.TrackingURL,
"shipped_at": shippedAt,
}
if order.Status == models.OrderStatusPaid {
updates["status"] = models.OrderStatusShipped
shipment := models.Shipment{
OrderID: order.ID,
Carrier: req.Carrier,
TrackingNumber: req.TrackingNumber,
TrackingURL: req.TrackingURL,
Status: models.ShipmentStatusLabelCreated,
ShippedAt: shippedAt,
}
if err := db.Model(&order).Updates(updates).Error; err != nil {
utils.RespondError(w, http.StatusInternalServerError, "DATABASE_ERROR", "failed to update order", nil)
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 {
utils.RespondError(w, http.StatusInternalServerError, "DATABASE_ERROR", "failed to record shipment", nil)
return
}
@@ -159,8 +170,9 @@ 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": updates["status"],
"status": order.Status,
})
}

View File

@@ -3,9 +3,11 @@ package database
import (
"fmt"
"log"
"time"
"terminalShop/pkg/models"
"gorm.io/gorm"
"terminalShop/pkg/models"
)
// Migrate runs database migrations
@@ -15,18 +17,58 @@ func Migrate(db *gorm.DB) error {
// AutoMigrate will create tables, missing columns, and indexes
// It will NOT delete unused columns or change column types
if err := db.AutoMigrate(
&models.Cart{}, // Server-side shipping cart
&models.CartItem{}, // Cart line items
&models.Coffee{},
&models.Cart{}, // Server-side shopping cart
&models.CartItem{}, // Cart line items
&models.Coffee{}, // Coffees
&models.User{}, // SSH key-based authentication
&models.Card{}, // Saved payment methods
&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)
}
log.Println("Database migrations completed successfully")
return nil
}
func backfillShipments(db *gorm.DB) error {
if !db.Migrator().HasColumn(&models.Order{}, "carrier") {
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
}

View File

@@ -26,29 +26,26 @@ type Order struct {
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
Carrier string `gorm:"size:100" json:"carrier"` // USPS, Posten (Norge!!), "FedEx"
TrackingNumber string `gorm:"size:255;index" json:"tracking_number"` // Tracking number of an order
TrackingURL string `gorm:"size:255" json:"tracking_url"` // carrier specific tracking link
ShippedAt *time.Time `json:"shipped_at"` // time the order was shipped at. Null until dispatched
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
// Associations
User User `gorm:"foreignKey:UserID" json:"-"`
Card Card `gorm:"foreignKey:CardID" json:"-"`
Items []OrderItem `gorm:"foreignKey:OrderID" json:"items"`
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"`
}
// TableName specifies the table name for the Order model.

43
pkg/models/shipment.go Normal file
View File

@@ -0,0 +1,43 @@
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

@@ -135,21 +135,22 @@ func (m Model) buildOrderDetailView(order models.Order, _ int) string {
b.WriteString(labelStyle.Render(fmt.Sprintf(" %-22s %s", "Total", total)))
b.WriteString("\n\n")
// Shipping (only when carrier is set)
if order.Carrier != "" {
// Shipping (only when at least one shipment exists)
if len(order.Shipments) > 0 {
s := order.Shipments[0]
b.WriteString(labelStyle.Render("Shipping"))
b.WriteString("\n")
b.WriteString(valueStyle.Render(fmt.Sprintf(" %-10s %s", "Carrier", order.Carrier)))
b.WriteString(valueStyle.Render(fmt.Sprintf(" %-10s %s", "Carrier", s.Carrier)))
b.WriteString("\n")
b.WriteString(valueStyle.Render(fmt.Sprintf(" %-10s %s", "Tracking", order.TrackingNumber)))
b.WriteString(valueStyle.Render(fmt.Sprintf(" %-10s %s", "Tracking", s.TrackingNumber)))
b.WriteString("\n")
if order.ShippedAt != nil {
if s.ShippedAt != nil {
b.WriteString(valueStyle.Render(fmt.Sprintf(" %-10s %s",
"Shipped", order.ShippedAt.Format("Jan 02 2006"))))
"Shipped", s.ShippedAt.Format("Jan 02 2006"))))
b.WriteString("\n")
}
if order.TrackingURL != "" {
b.WriteString(dimStyle.Render(" " + order.TrackingURL))
if s.TrackingURL != "" {
b.WriteString(dimStyle.Render(" " + s.TrackingURL))
b.WriteString("\n")
}
}