diff --git a/api/handlers/cart.go b/api/handlers/cart.go index a433ffe..e44a4db 100644 --- a/api/handlers/cart.go +++ b/api/handlers/cart.go @@ -18,6 +18,7 @@ import ( "terminalShop/pkg/audit" "terminalShop/pkg/database" "terminalShop/pkg/models" + "terminalShop/pkg/notify" "terminalShop/pkg/utils" ) @@ -482,6 +483,7 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) { utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to preload items", nil) return } + go notify.SlackOrderPaid(&order) utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{ "order": order, diff --git a/api/handlers/webhook.go b/api/handlers/webhook.go index ba80a57..01b6fd6 100644 --- a/api/handlers/webhook.go +++ b/api/handlers/webhook.go @@ -19,6 +19,7 @@ import ( "terminalShop/pkg/audit" "terminalShop/pkg/database" "terminalShop/pkg/models" + "terminalShop/pkg/notify" "terminalShop/pkg/utils" ) @@ -113,7 +114,7 @@ func (h *WebhookHandler) handlePaymentIntentSucceeded(ctx context.Context, event db := database.GetDB().WithContext(ctx) var order models.Order - if err := db.Where("id = ?", orderIDStr).First(&order).Error; err != nil { + if err := db.Preload("Items").Where("id = ?", orderIDStr).First(&order).Error; err != nil { webhookLog().Warn("order not found for payment intent", "order_id", orderIDStr, "pi", pi.ID) return } @@ -150,6 +151,7 @@ func (h *WebhookHandler) handlePaymentIntentSucceeded(ctx context.Context, event } audit.OrderPaid(order.UserID, order.ID, int(pi.Amount), pi.ID) + go notify.SlackOrderPaid(&order) webhookLog().Info("order marked paid", "order_id", orderIDStr, "pi", pi.ID) } diff --git a/pkg/audit/audit.go b/pkg/audit/audit.go index c95d723..c2412ca 100644 --- a/pkg/audit/audit.go +++ b/pkg/audit/audit.go @@ -5,7 +5,6 @@ package audit import ( "log/slog" - "terminalShop/pkg/notify" ) type eventType string @@ -117,7 +116,6 @@ func OrderPaid(userID, orderID uint, amountCents int, stripePaymentIntentID stri Amount: amountCents, StripeID: stripePaymentIntentID, }.attrs()...) - go notify.SlackOrderPaid(orderID, amountCents) } // OrderShipped records that an order has been marked as shipped with carrier metadata diff --git a/pkg/notify/slack.go b/pkg/notify/slack.go index c82353f..f7d58ef 100644 --- a/pkg/notify/slack.go +++ b/pkg/notify/slack.go @@ -8,57 +8,94 @@ import ( "net/http" "os" "time" + + "terminalShop/pkg/models" ) var slackClient = &http.Client{Timeout: 5 * time.Second} -type slackMessage struct { - Channel string `json:"channel"` - Text string `json:"text"` - Blocks []slackBlock `json:"blocks,omitempty"` +func dollars(cents int) string { + return fmt.Sprintf("$%.2f", float64(cents)/100) } -type slackBlock map[string]any - -func SlackOrderPaid(orderID uint, amountCents int) { +// SlackOrderPaid posts a receipt-style Block Kit message with item lines, +// totals, shipping address, and a "Buy Label" button. Caller must preload +// order.Items before calling. No-op if SLACK_BOT_TOKEN or SLACK_CHANNEL unset. +func SlackOrderPaid(order *models.Order) { token := os.Getenv("SLACK_BOT_TOKEN") channel := os.Getenv("SLACK_CHANNEL") if token == "" || channel == "" { return } - text := fmt.Sprintf(":coffee: Order #%d paid - $%.2f", orderID, float64(amountCents)/100) - msg := slackMessage{ - Channel: channel, - Text: text, - Blocks: []slackBlock{ - { - "type": "section", - "text": map[string]string{"type": "mrkdwn", "text": text}, + fallback := fmt.Sprintf(":coffee: Order #%d paid - %s", order.ID, dollars(order.Total)) + + blocks := []map[string]any{ + { + "type": "header", + "text": map[string]any{"type": "plain_text", "text": fallback, "emoji": true}, + }, + {"type": "divider"}, + } + + for _, item := range order.Items { + line := fmt.Sprintf("*%s* × %d\n%s", item.Name, item.Quantity, dollars(item.Price*item.Quantity)) + blocks = append(blocks, map[string]any{ + "type": "section", + "text": map[string]any{"type": "mrkdwn", "text": line}, + }) + } + + blocks = append(blocks, + map[string]any{"type": "divider"}, + map[string]any{ + "type": "section", + "fields": []map[string]any{ + {"type": "mrkdwn", "text": "*Subtotal:*\n" + dollars(order.Subtotal)}, + {"type": "mrkdwn", "text": "*Shipping:*\n" + dollars(order.ShippingCost)}, + {"type": "mrkdwn", "text": "*Total:*\n" + dollars(order.Total)}, }, - { - "type": "actions", - "elements": []slackBlock{ - { - "type": "button", - "text": map[string]string{"type": "plain_text", "text": "Buy Label"}, - "value": fmt.Sprintf("%d", orderID), - "action_id": "buy_label", - "style": "primary", - }, + }, + map[string]any{ + "type": "section", + "text": map[string]any{ + "type": "mrkdwn", + "text": fmt.Sprintf(":package: *Ship to:*\n%s\n%s\n%s, %s %s", + order.ShippingName, + order.ShippingStreet, + order.ShippingCity, + order.ShippingZip, + order.ShippingCountry, + ), + }, + }, + map[string]any{ + "type": "actions", + "elements": []map[string]any{ + { + "type": "button", + "text": map[string]any{"type": "plain_text", "text": "Buy Label", "emoji": true}, + "value": fmt.Sprintf("%d", order.ID), + "action_id": "buy_label", + "style": "primary", }, }, }, - } + ) + + body, _ := json.Marshal(map[string]any{ + "channel": channel, + "text": fallback, + "blocks": blocks, + }) - body, _ := json.Marshal(msg) req, _ := http.NewRequest(http.MethodPost, "https://slack.com/api/chat.postMessage", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json; charset=utf-8") req.Header.Set("Authorization", "Bearer "+token) resp, err := slackClient.Do(req) if err != nil { - slog.Warn("slack notify failed", "error", err, "order_id", orderID) + slog.Warn("slack notify failed", "error", err, "order_id", order.ID) return } defer resp.Body.Close() @@ -69,7 +106,7 @@ func SlackOrderPaid(orderID uint, amountCents int) { } _ = json.NewDecoder(resp.Body).Decode(&parsed) if !parsed.Ok { - slog.Warn("slack notify non-ok", "error", parsed.Error, "order_id", orderID) + slog.Warn("slack notify non-ok", "error", parsed.Error, "order_id", order.ID) } }