feat(notify): render slack order notification as block kit receipt with line items
Some checks failed
Deploy / deploy (push) Has been cancelled

This commit is contained in:
2026-05-15 12:11:16 +02:00
parent 0e4d267adb
commit 2966474ff5
4 changed files with 71 additions and 32 deletions

View File

@@ -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,

View File

@@ -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)
}

View File

@@ -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

View File

@@ -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)
}
}