mirror of
https://github.com/marcusDenslow/terminalShop.git
synced 2026-10-25 02:52:20 +02:00
refactor(orders): embed tracking on orders, drop shipments table
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:
@@ -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,
|
||||
}
|
||||
|
||||
err = db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Create(&shipment).Error; err != nil {
|
||||
return err
|
||||
updates := map[string]any{
|
||||
"carrier": req.Carrier,
|
||||
"tracking_number": req.TrackingNumber,
|
||||
"tracking_url": req.TrackingURL,
|
||||
"shipped_at": shippedAt,
|
||||
}
|
||||
if order.Status == models.OrderStatusPaid {
|
||||
if err := tx.Model(&order).Update("status", models.OrderStatusShipped).Error; err != nil {
|
||||
return err
|
||||
updates["status"] = models.OrderStatusShipped
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -13,12 +13,23 @@ const (
|
||||
OrderStatusPending OrderStatus = "pending"
|
||||
OrderStatusPaid OrderStatus = "paid"
|
||||
OrderStatusShipped OrderStatus = "shipped"
|
||||
// OrderStatusDelivered OrderStatus = "delivered"
|
||||
// OrderStatusCancelled OrderStatus = "cancelled"
|
||||
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"`
|
||||
@@ -37,6 +48,16 @@ type Order struct {
|
||||
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
|
||||
@@ -45,7 +66,6 @@ type Order struct {
|
||||
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.
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user