756 lines
23 KiB
Go
756 lines
23 KiB
Go
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)
|
|
}
|