mirror of
https://github.com/marcusDenslow/terminalShop.git
synced 2026-10-25 02:52:20 +02:00
feat: extract shipments to dedicated table
This commit is contained in:
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
43
pkg/models/shipment.go
Normal 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"
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user