diff --git a/api/handlers/orders.go b/api/handlers/orders.go index f25f71e..f56cce7 100644 --- a/api/handlers/orders.go +++ b/api/handlers/orders.go @@ -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, }) } diff --git a/pkg/database/migrate.go b/pkg/database/migrate.go index 44a838e..8da11b9 100644 --- a/pkg/database/migrate.go +++ b/pkg/database/migrate.go @@ -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 +} diff --git a/pkg/models/order.go b/pkg/models/order.go index 8f3ffbf..1a11135 100644 --- a/pkg/models/order.go +++ b/pkg/models/order.go @@ -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. diff --git a/pkg/models/shipment.go b/pkg/models/shipment.go new file mode 100644 index 0000000..ea6db7a --- /dev/null +++ b/pkg/models/shipment.go @@ -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" +} diff --git a/pkg/tui/account_orders.go b/pkg/tui/account_orders.go index f68bce5..48ef269 100644 --- a/pkg/tui/account_orders.go +++ b/pkg/tui/account_orders.go @@ -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") } }