package httpx import ( "context" "crypto/hmac" "crypto/sha256" "crypto/subtle" "encoding/base64" "encoding/hex" "encoding/json" "fmt" "log" "net/http" "strconv" "strings" "time" "github.com/go-chi/chi/v5" "royal-pop-backend/internal/config" "royal-pop-backend/internal/database" "royal-pop-backend/internal/inventory" "royal-pop-backend/internal/mailer" "royal-pop-backend/internal/orders" ) const clientSessionCookieName = "royal_pop_client_session" type clientLoginRequest struct { Username string `json:"username"` Password string `json:"password"` } type clientStockUpdateRequest struct { Style string `json:"style"` ColorwayID string `json:"colorwayId"` FinishID string `json:"finishId"` QuantityOnHand int64 `json:"quantityOnHand"` Notes string `json:"notes"` } type clientOrderUpdateRequest struct { FulfillmentStatus string `json:"fulfillmentStatus"` ShippingCarrier string `json:"shippingCarrier"` TrackingNumber string `json:"trackingNumber"` FulfillmentNotes string `json:"fulfillmentNotes"` ClearShippedAt bool `json:"clearShippedAt"` MarkShippedAtNow bool `json:"markShippedAtNow"` } func handleClientReseed(db *database.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if db == nil { WriteError(w, http.StatusServiceUnavailable, "reseed_unavailable", "Database reseed is not configured yet.") return } ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) defer cancel() summary, err := db.BackupAndResetAppData(ctx) if err != nil { WriteError(w, http.StatusInternalServerError, "reseed_failed", "The database backup or reset could not be completed.") return } WriteJSON(w, http.StatusOK, map[string]any{ "data": map[string]any{ "backupPath": summary.BackupPath, "ordersCount": summary.OrdersCount, "orderItemsCount": summary.OrderItemsCount, "inventoryCount": summary.InventoryCount, "message": "Backup created and the local database has been reset to an empty state.", }, }) } } func handleListPublicInventory(stockStore *inventory.Store) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if stockStore == nil { WriteJSON(w, http.StatusOK, map[string]any{ "data": map[string]any{ "inventory": []map[string]any{}, }, }) return } levels, err := stockStore.List(r.Context()) if err != nil { WriteError(w, http.StatusInternalServerError, "inventory_list_failed", "The storefront could not load inventory availability.") return } serialized := make([]map[string]any, 0, len(levels)) for _, level := range levels { serialized = append(serialized, map[string]any{ "style": level.Style, "colorwayId": level.ColorwayID, "finishId": level.FinishID, "quantityOnHand": level.QuantityOnHand, "updatedAt": level.UpdatedAt.Format(time.RFC3339), }) } WriteJSON(w, http.StatusOK, map[string]any{ "data": map[string]any{ "inventory": serialized, }, }) } } func handleGetClientSession(cfg *config.Config) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if !cfg.ClientDashboardEnabled() { WriteJSON(w, http.StatusOK, map[string]any{ "data": map[string]any{ "authenticated": false, "configured": false, }, }) return } username, expiresAt, ok := validateClientSessionCookie(cfg, r) WriteJSON(w, http.StatusOK, map[string]any{ "data": map[string]any{ "authenticated": ok, "configured": true, "username": username, "expiresAt": expiresAt, }, }) } } func handleClientLogin(cfg *config.Config) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if !cfg.ClientDashboardEnabled() { WriteError(w, http.StatusServiceUnavailable, "client_dashboard_not_configured", "Client dashboard credentials are not configured yet.") return } var request clientLoginRequest if err := json.NewDecoder(r.Body).Decode(&request); err != nil { WriteError(w, http.StatusBadRequest, "invalid_json", "Provide a valid username and password.") return } if subtle.ConstantTimeCompare([]byte(strings.TrimSpace(request.Username)), []byte(cfg.ClientDashboardUser)) != 1 || subtle.ConstantTimeCompare([]byte(request.Password), []byte(cfg.ClientDashboardPass)) != 1 { WriteError(w, http.StatusUnauthorized, "invalid_client_credentials", "The client dashboard username or password is incorrect.") return } expiresAt := time.Now().Add(cfg.ClientDashboardSessionTTL) setClientSessionCookie(w, cfg, cfg.ClientDashboardUser, expiresAt) WriteJSON(w, http.StatusOK, map[string]any{ "data": map[string]any{ "authenticated": true, "configured": true, "username": cfg.ClientDashboardUser, "expiresAt": expiresAt.Format(time.RFC3339), }, }) } } func handleClientLogout(cfg *config.Config) http.HandlerFunc { return func(w http.ResponseWriter, _ *http.Request) { clearClientSessionCookie(w, cfg) WriteJSON(w, http.StatusOK, map[string]any{ "data": map[string]any{ "authenticated": false, }, }) } } func requireClientSession(cfg *config.Config) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if !cfg.ClientDashboardEnabled() { WriteError(w, http.StatusServiceUnavailable, "client_dashboard_not_configured", "Client dashboard credentials are not configured yet.") return } if _, _, ok := validateClientSessionCookie(cfg, r); !ok { WriteError(w, http.StatusUnauthorized, "client_dashboard_auth_required", "Please sign in to the client dashboard first.") return } next.ServeHTTP(w, r) }) } } func handleListClientOrders(orderStore *orders.Store) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if orderStore == nil { WriteError(w, http.StatusServiceUnavailable, "orders_unavailable", "Order storage is not configured yet.") return } limit := 25 if raw := strings.TrimSpace(r.URL.Query().Get("limit")); raw != "" { parsed, err := strconv.Atoi(raw) if err == nil { limit = parsed } } search := strings.TrimSpace(r.URL.Query().Get("search")) status := strings.TrimSpace(r.URL.Query().Get("status")) ordersList, err := orderStore.ListForDashboard(r.Context(), orders.ListFilter{ Limit: limit, Search: search, FulfillmentStatus: status, }) if err != nil { WriteError(w, http.StatusInternalServerError, "orders_list_failed", "The client dashboard could not load recent orders.") return } serialized := make([]map[string]any, 0, len(ordersList)) for _, order := range ordersList { serialized = append(serialized, serializeClientOrderSummary(order)) } WriteJSON(w, http.StatusOK, map[string]any{ "data": map[string]any{ "orders": serialized, }, }) } } func handleGetClientOrder(orderStore *orders.Store) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if orderStore == nil { WriteError(w, http.StatusServiceUnavailable, "orders_unavailable", "Order storage is not configured yet.") return } orderID := strings.TrimSpace(chi.URLParam(r, "orderID")) if orderID == "" { WriteError(w, http.StatusBadRequest, "invalid_order_id", "Provide a valid order ID.") return } order, err := orderStore.GetByID(r.Context(), orderID) if err != nil { if err == orders.ErrOrderNotFound { WriteError(w, http.StatusNotFound, "order_not_found", "That order could not be found.") return } WriteError(w, http.StatusInternalServerError, "order_load_failed", "The client dashboard could not load this order.") return } WriteJSON(w, http.StatusOK, map[string]any{ "data": map[string]any{ "order": serializeClientOrderDetail(order), }, }) } } func handleUpdateClientOrder(orderStore *orders.Store, fulfillmentMailer *mailer.ResendMailer) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if orderStore == nil { WriteError(w, http.StatusServiceUnavailable, "orders_unavailable", "Order storage is not configured yet.") return } orderID := strings.TrimSpace(chi.URLParam(r, "orderID")) if orderID == "" { WriteError(w, http.StatusBadRequest, "invalid_order_id", "Provide a valid order ID.") return } existingOrder, err := orderStore.GetByID(r.Context(), orderID) if err != nil { if err == orders.ErrOrderNotFound { WriteError(w, http.StatusNotFound, "order_not_found", "That order could not be found.") return } WriteError(w, http.StatusInternalServerError, "order_load_failed", "The client dashboard could not load this order.") return } var request clientOrderUpdateRequest if err := json.NewDecoder(r.Body).Decode(&request); err != nil { WriteError(w, http.StatusBadRequest, "invalid_json", "Provide a valid order update payload.") return } updatedOrder, err := orderStore.UpdateFulfillment(r.Context(), orders.UpdateFulfillmentInput{ OrderID: orderID, FulfillmentStatus: request.FulfillmentStatus, ShippingCarrier: request.ShippingCarrier, TrackingNumber: request.TrackingNumber, FulfillmentNotes: request.FulfillmentNotes, ClearShippedAt: request.ClearShippedAt, MarkShippedAtNow: request.MarkShippedAtNow, }) if err != nil { switch err { case orders.ErrOrderNotFound: WriteError(w, http.StatusNotFound, "order_not_found", "That order could not be found.") return default: if strings.Contains(err.Error(), "invalid fulfillment status") { WriteError(w, http.StatusBadRequest, "invalid_fulfillment_status", "Choose a valid fulfillment status.") return } WriteError(w, http.StatusInternalServerError, "order_update_failed", "The order update could not be saved.") return } } shipmentEmail := map[string]any{ "status": "skipped", "message": "Fulfilment email was not sent.", } if fulfillmentMailer == nil || !fulfillmentMailer.Enabled() { shipmentEmail["reason"] = "not_configured" shipmentEmail["message"] = "Fulfilment email is not configured." } else if shouldSendShipmentEmail(existingOrder, updatedOrder) { if err := fulfillmentMailer.SendOrderShipped(r.Context(), updatedOrder); err != nil { log.Printf("client order shipment email failed for %s: %v", updatedOrder.ID, err) shipmentEmail["status"] = "failed" shipmentEmail["message"] = "The order was updated, but the shipment email could not be sent." shipmentEmail["reason"] = "send_failed" } else { shipmentEmail["status"] = "sent" shipmentEmail["message"] = "Shipment email sent to the customer." } } else if shouldSendCancellationEmail(existingOrder, updatedOrder) { if err := fulfillmentMailer.SendOrderCancelled(r.Context(), updatedOrder); err != nil { log.Printf("client order cancellation email failed for %s: %v", updatedOrder.ID, err) shipmentEmail["status"] = "failed" shipmentEmail["message"] = "The order was updated, but the cancellation email could not be sent." shipmentEmail["reason"] = "send_failed" } else { shipmentEmail["status"] = "sent" shipmentEmail["message"] = "Cancellation email sent to the customer." } } else if shouldSendDeliveredEmail(existingOrder, updatedOrder) { if err := fulfillmentMailer.SendOrderDelivered(r.Context(), updatedOrder); err != nil { log.Printf("client order delivered email failed for %s: %v", updatedOrder.ID, err) shipmentEmail["status"] = "failed" shipmentEmail["message"] = "The order was updated, but the delivered email could not be sent." shipmentEmail["reason"] = "send_failed" } else { shipmentEmail["status"] = "sent" shipmentEmail["message"] = "Delivered email sent to the customer." } } else { shipmentEmail["reason"] = shipmentEmailSkipReason(existingOrder, updatedOrder) } WriteJSON(w, http.StatusOK, map[string]any{ "data": map[string]any{ "order": serializeClientOrderDetail(updatedOrder), "shipmentEmail": shipmentEmail, }, }) } } func shouldSendShipmentEmail(previousOrder, updatedOrder *orders.Order) bool { if previousOrder == nil || updatedOrder == nil { return false } if !strings.EqualFold(updatedOrder.FulfillmentStatus, orders.FulfillmentShipped) { return false } if strings.EqualFold(previousOrder.FulfillmentStatus, orders.FulfillmentShipped) { return false } if strings.TrimSpace(updatedOrder.Email) == "" { return false } if strings.TrimSpace(updatedOrder.ShippingCarrier) == "" || strings.TrimSpace(updatedOrder.TrackingNumber) == "" { return false } return true } func shouldSendCancellationEmail(previousOrder, updatedOrder *orders.Order) bool { if previousOrder == nil || updatedOrder == nil { return false } if !strings.EqualFold(updatedOrder.FulfillmentStatus, orders.FulfillmentCancelled) { return false } if strings.EqualFold(previousOrder.FulfillmentStatus, orders.FulfillmentCancelled) { return false } if strings.TrimSpace(updatedOrder.Email) == "" { return false } return true } func shouldSendDeliveredEmail(previousOrder, updatedOrder *orders.Order) bool { if previousOrder == nil || updatedOrder == nil { return false } if !strings.EqualFold(updatedOrder.FulfillmentStatus, orders.FulfillmentDelivered) { return false } if strings.EqualFold(previousOrder.FulfillmentStatus, orders.FulfillmentDelivered) { return false } if strings.TrimSpace(updatedOrder.Email) == "" { return false } return true } func shipmentEmailSkipReason(previousOrder, updatedOrder *orders.Order) string { if updatedOrder == nil { return "order_unavailable" } if strings.EqualFold(updatedOrder.FulfillmentStatus, orders.FulfillmentShipped) { if previousOrder != nil && strings.EqualFold(previousOrder.FulfillmentStatus, orders.FulfillmentShipped) { return "already_shipped" } if strings.TrimSpace(updatedOrder.Email) == "" { return "missing_email" } if strings.TrimSpace(updatedOrder.ShippingCarrier) == "" || strings.TrimSpace(updatedOrder.TrackingNumber) == "" { return "missing_tracking" } return "not_applicable" } if strings.EqualFold(updatedOrder.FulfillmentStatus, orders.FulfillmentCancelled) { if previousOrder != nil && strings.EqualFold(previousOrder.FulfillmentStatus, orders.FulfillmentCancelled) { return "already_cancelled" } if strings.TrimSpace(updatedOrder.Email) == "" { return "missing_email" } return "not_applicable" } if strings.EqualFold(updatedOrder.FulfillmentStatus, orders.FulfillmentDelivered) { if previousOrder != nil && strings.EqualFold(previousOrder.FulfillmentStatus, orders.FulfillmentDelivered) { return "already_delivered" } if strings.TrimSpace(updatedOrder.Email) == "" { return "missing_email" } return "not_applicable" } return "status_not_supported" } func handleListClientStock(stockStore *inventory.Store) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if stockStore == nil { WriteError(w, http.StatusServiceUnavailable, "stock_unavailable", "Stock storage is not configured yet.") return } levels, err := stockStore.List(r.Context()) if err != nil { WriteError(w, http.StatusInternalServerError, "stock_list_failed", "The client dashboard could not load stock levels.") return } serialized := make([]map[string]any, 0, len(levels)) for _, level := range levels { serialized = append(serialized, map[string]any{ "style": level.Style, "colorwayId": level.ColorwayID, "finishId": level.FinishID, "quantityOnHand": level.QuantityOnHand, "notes": level.Notes, "createdAt": level.CreatedAt.Format(time.RFC3339), "updatedAt": level.UpdatedAt.Format(time.RFC3339), }) } WriteJSON(w, http.StatusOK, map[string]any{ "data": map[string]any{ "stock": serialized, }, }) } } func handleUpsertClientStock(stockStore *inventory.Store) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if stockStore == nil { WriteError(w, http.StatusServiceUnavailable, "stock_unavailable", "Stock storage is not configured yet.") return } var request clientStockUpdateRequest if err := json.NewDecoder(r.Body).Decode(&request); err != nil { WriteError(w, http.StatusBadRequest, "invalid_json", "Provide a valid stock update payload.") return } request.Style = strings.TrimSpace(strings.ToUpper(request.Style)) request.ColorwayID = strings.TrimSpace(request.ColorwayID) request.FinishID = strings.TrimSpace(request.FinishID) request.Notes = strings.TrimSpace(request.Notes) if request.Style != "A" && request.Style != "B" { WriteError(w, http.StatusBadRequest, "invalid_style", "Stock entries must use style A or B.") return } if request.ColorwayID == "" || request.FinishID == "" { WriteError(w, http.StatusBadRequest, "invalid_stock_key", "Stock entries need a colorway and finish.") return } if request.QuantityOnHand < 0 { WriteError(w, http.StatusBadRequest, "invalid_quantity", "Stock quantity cannot be negative.") return } level, err := stockStore.Upsert(r.Context(), inventory.UpsertInput{ Style: request.Style, ColorwayID: request.ColorwayID, FinishID: request.FinishID, QuantityOnHand: request.QuantityOnHand, Notes: request.Notes, }) if err != nil { WriteError(w, http.StatusInternalServerError, "stock_save_failed", "The stock level could not be saved.") return } WriteJSON(w, http.StatusOK, map[string]any{ "data": map[string]any{ "stock": map[string]any{ "style": level.Style, "colorwayId": level.ColorwayID, "finishId": level.FinishID, "quantityOnHand": level.QuantityOnHand, "notes": level.Notes, "createdAt": level.CreatedAt.Format(time.RFC3339), "updatedAt": level.UpdatedAt.Format(time.RFC3339), }, }, }) } } func validateClientSessionCookie(cfg *config.Config, r *http.Request) (string, string, bool) { if cfg == nil || r == nil || !cfg.ClientDashboardEnabled() { return "", "", false } cookie, err := r.Cookie(clientSessionCookieName) if err != nil || strings.TrimSpace(cookie.Value) == "" { return "", "", false } decoded, err := base64.RawURLEncoding.DecodeString(cookie.Value) if err != nil { return "", "", false } parts := strings.Split(string(decoded), ".") if len(parts) != 3 { return "", "", false } username := parts[0] expiresUnix, err := strconv.ParseInt(parts[1], 10, 64) if err != nil { return "", "", false } expiresAt := time.Unix(expiresUnix, 0).UTC() if time.Now().After(expiresAt) { return "", "", false } expectedSignature := signClientSession(cfg.ClientDashboardSigningSecret(), username, expiresUnix) if subtle.ConstantTimeCompare([]byte(parts[2]), []byte(expectedSignature)) != 1 { return "", "", false } if subtle.ConstantTimeCompare([]byte(username), []byte(cfg.ClientDashboardUser)) != 1 { return "", "", false } return username, expiresAt.Format(time.RFC3339), true } func setClientSessionCookie(w http.ResponseWriter, cfg *config.Config, username string, expiresAt time.Time) { expiresUnix := expiresAt.UTC().Unix() token := fmt.Sprintf("%s.%d.%s", username, expiresUnix, signClientSession(cfg.ClientDashboardSigningSecret(), username, expiresUnix)) http.SetCookie(w, &http.Cookie{ Name: clientSessionCookieName, Value: base64.RawURLEncoding.EncodeToString([]byte(token)), Path: "/", Expires: expiresAt, MaxAge: int(time.Until(expiresAt).Seconds()), HttpOnly: true, SameSite: http.SameSiteLaxMode, Secure: !cfg.IsDevelopment(), }) } func clearClientSessionCookie(w http.ResponseWriter, cfg *config.Config) { http.SetCookie(w, &http.Cookie{ Name: clientSessionCookieName, Value: "", Path: "/", Expires: time.Unix(0, 0), MaxAge: -1, HttpOnly: true, SameSite: http.SameSiteLaxMode, Secure: !cfg.IsDevelopment(), }) } func signClientSession(secret, username string, expiresUnix int64) string { mac := hmac.New(sha256.New, []byte(secret)) _, _ = mac.Write([]byte(fmt.Sprintf("%s|%d", username, expiresUnix))) return hex.EncodeToString(mac.Sum(nil)) } func serializeClientOrderSummary(order orders.Order) map[string]any { itemCount := int64(0) items := make([]map[string]any, 0, len(order.Items)) for _, item := range order.Items { itemCount += item.Quantity items = append(items, map[string]any{ "style": item.Style, "colorwayId": item.ColorwayID, "finishId": item.FinishID, "quantity": item.Quantity, "unitAmount": item.UnitAmount, }) } return map[string]any{ "id": order.ID, "status": order.Status, "fulfillmentStatus": order.FulfillmentStatus, "email": order.Email, "phone": order.Phone, "firstName": order.FirstName, "lastName": order.LastName, "city": order.City, "region": order.Region, "postalCode": order.PostalCode, "country": order.Country, "notes": order.Notes, "currency": order.Currency, "amount": order.Amount, "shippingCarrier": order.ShippingCarrier, "trackingNumber": order.TrackingNumber, "webhookStatus": order.WebhookStatus, "webhookMessage": order.WebhookMessage, "createdAt": order.CreatedAt.Format(time.RFC3339), "updatedAt": order.UpdatedAt.Format(time.RFC3339), "shippedAt": formatOptionalTime(order.ShippedAt), "itemCount": itemCount, "items": items, } } func serializeClientOrderDetail(order *orders.Order) map[string]any { if order == nil { return map[string]any{} } items := make([]map[string]any, 0, len(order.Items)) for _, item := range order.Items { items = append(items, map[string]any{ "itemId": item.ItemID, "style": item.Style, "colorwayId": item.ColorwayID, "finishId": item.FinishID, "quantity": item.Quantity, "unitAmount": item.UnitAmount, }) } return map[string]any{ "id": order.ID, "status": order.Status, "fulfillmentStatus": order.FulfillmentStatus, "email": order.Email, "phone": order.Phone, "firstName": order.FirstName, "lastName": order.LastName, "addressLine1": order.AddressLine1, "addressLine2": order.AddressLine2, "city": order.City, "region": order.Region, "postalCode": order.PostalCode, "country": order.Country, "notes": order.Notes, "currency": order.Currency, "amount": order.Amount, "shippingCarrier": order.ShippingCarrier, "trackingNumber": order.TrackingNumber, "fulfillmentNotes": order.FulfillmentNotes, "webhookStatus": order.WebhookStatus, "webhookEventId": order.WebhookEventID, "webhookEventType": order.WebhookEventType, "webhookMessage": order.WebhookMessage, "stripePaymentIntentId": order.StripePaymentIntentID, "createdAt": order.CreatedAt.Format(time.RFC3339), "updatedAt": order.UpdatedAt.Format(time.RFC3339), "shippedAt": formatOptionalTime(order.ShippedAt), "items": items, } } func formatOptionalTime(value *time.Time) any { if value == nil { return nil } return value.UTC().Format(time.RFC3339) }