diff --git a/api/handlers/slack.go b/api/handlers/slack.go index 17206a8..9b09780 100644 --- a/api/handlers/slack.go +++ b/api/handlers/slack.go @@ -8,6 +8,7 @@ import ( "encoding/json" "fmt" "io" + "log/slog" "net/http" "net/url" "os" @@ -98,16 +99,17 @@ func (h *SlackHandler) dispatch(action slackAction, p slackInteractivePayload) { case "buy_label": h.handleBuyLabel(action.Value, p) default: - notify.SlackPostToResponseURL(p.ResponseURL, fmt.Sprintf("Unknown action: %s", action.ActionID)) + slog.Warn("slack interactivity: unknown action", "action_id", action.ActionID) } } func (h *SlackHandler) handleBuyLabel(orderIDStr string, p slackInteractivePayload) { - orderID, err := strconv.ParseUint(orderIDStr, 10, 32) + orderID64, err := strconv.ParseUint(orderIDStr, 10, 32) if err != nil { - notify.SlackPostToResponseURL(p.ResponseURL, fmt.Sprintf(":x: Invalid order id `%s`", orderIDStr)) + slog.Warn("slack buy_label: invalid order id", "value", orderIDStr) return } + orderID := uint(orderID64) url := fmt.Sprintf("http://localhost:%s/api/v1/orders/%d/label", h.apiPort, orderID) req, _ := http.NewRequestWithContext(context.Background(), http.MethodPost, url, nil) @@ -115,7 +117,7 @@ func (h *SlackHandler) handleBuyLabel(orderIDStr string, p slackInteractivePaylo resp, err := h.httpClient.Do(req) if err != nil { - notify.SlackPostToResponseURL(p.ResponseURL, fmt.Sprintf(":x: Label purchase failed: %v", err)) + notify.SlackPostToOrderThread(orderID, fmt.Sprintf(":x: Label purchase failed: %v", err)) return } defer resp.Body.Close() @@ -125,10 +127,10 @@ func (h *SlackHandler) handleBuyLabel(orderIDStr string, p slackInteractivePaylo TrackingNumber string `json:"tracking_number"` Carrier string `json:"carrier"` LabelURL string `json:"label_url"` - CostCents int `json:"cost_cents"` + CostCents int `json:"cost_cents"` } `json:"data"` Error struct { - Code string `json:"code"` + Code string `json:"code"` Message string `json:"message"` } `json:"error"` } @@ -136,20 +138,17 @@ func (h *SlackHandler) handleBuyLabel(orderIDStr string, p slackInteractivePaylo _ = json.Unmarshal(bodyBytes, &result) if resp.StatusCode >= 300 { - msg := fmt.Sprintf(":x: Order #%d label failed: `%s` %s", orderID, result.Error.Code, result.Error.Message) - notify.SlackPostToResponseURL(p.ResponseURL, msg) + notify.SlackPostToOrderThread(orderID, fmt.Sprintf(":x: Label purchase failed: `%s` %s", result.Error.Code, result.Error.Message)) return } - msg := fmt.Sprintf( - ":white_check_mark: Order #%d labeled — %s `%s` ($%.2f). <%s|Download label PDF>", - orderID, + notify.SlackPostToOrderThread(orderID, fmt.Sprintf( + ":white_check_mark: Labeled — %s `%s` ($%.2f). <%s|Download label PDF>", result.Data.Carrier, result.Data.TrackingNumber, float64(result.Data.CostCents)/100, result.Data.LabelURL, - ) - notify.SlackPostToResponseURL(p.ResponseURL, msg) + )) } func (h *SlackHandler) verifySignature(r *http.Request, body []byte) bool { diff --git a/api/handlers/webhook.go b/api/handlers/webhook.go index 01b6fd6..84cb5e0 100644 --- a/api/handlers/webhook.go +++ b/api/handlers/webhook.go @@ -347,6 +347,7 @@ func (h *WebhookHandler) HandleShippo(w http.ResponseWriter, r *http.Request) { if previousTrackingStatus != newStatus { audit.TrackingUpdated(order.ID, order.Carrier, payload.Data.TrackingNumber, string(newStatus)) + go notify.SlackPostToOrderThread(order.ID, formatTrackingUpdate(newStatus, payload.Data.TrackingStatus.StatusDetails)) } if deliveredTransition { audit.OrderDelivered(order.ID, payload.Data.TrackingNumber) @@ -362,6 +363,28 @@ func (h *WebhookHandler) HandleShippo(w http.ResponseWriter, r *http.Request) { }) } +func formatTrackingUpdate(status models.TrackingStatus, details string) string { + var icon, label string + switch status { + case models.TrackingStatusPreTransit: + icon, label = ":label:", "Label created" + case models.TrackingStatusTransit: + icon, label = ":truck:", "In transit" + case models.TrackingStatusDelivered: + icon, label = ":white_check_mark:", "Delivered" + case models.TrackingStatusReturned: + icon, label = ":leftwards_arrow_with_hook:", "Returned to sender" + case models.TrackingStatusFailure: + icon, label = ":warning:", "Delivery exception" + default: + icon, label = ":package:", string(status) + } + if details != "" { + return fmt.Sprintf("%s *%s* — %s", icon, label, details) + } + return fmt.Sprintf("%s *%s*", icon, label) +} + func mapShippoStatus(s string) (models.TrackingStatus, bool) { switch strings.ToUpper(strings.TrimSpace(s)) { case "UNKNOWN": diff --git a/pkg/models/order.go b/pkg/models/order.go index 8cb87e1..53a0593 100644 --- a/pkg/models/order.go +++ b/pkg/models/order.go @@ -60,8 +60,11 @@ type Order struct { // Shippo label shit, populated by the PurchaseLabel handler ShippoTransactionID *string `gorm:"size:255;uniqueIndex" json:"-"` - LabelURL string `gorm:"size:512" json:"-"` - LabelCostCents int `json:"-"` + LabelURL string `gorm:"size:512" json:"-"` + LabelCostCents int `json:"-"` + + // Slack parent message ts for this order's thread (NULL if Slack notify disabled). + SlackThreadTS *string `gorm:"size:64" json:"-"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` diff --git a/pkg/notify/slack.go b/pkg/notify/slack.go index f7d58ef..fcc1843 100644 --- a/pkg/notify/slack.go +++ b/pkg/notify/slack.go @@ -9,6 +9,7 @@ import ( "os" "time" + "terminalShop/pkg/database" "terminalShop/pkg/models" ) @@ -18,26 +19,37 @@ func dollars(cents int) string { return fmt.Sprintf("$%.2f", float64(cents)/100) } -// 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 +// postSlackMessage POSTs to chat.postMessage and returns (ts, ok). Logs on failure. +func postSlackMessage(token string, payload map[string]any) (string, bool) { + body, _ := json.Marshal(payload) + 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 post failed", "error", err) + return "", false } + defer resp.Body.Close() - fallback := fmt.Sprintf(":coffee: Order #%d paid - %s", order.ID, dollars(order.Total)) + var parsed struct { + Ok bool `json:"ok"` + TS string `json:"ts"` + Error string `json:"error"` + } + _ = json.NewDecoder(resp.Body).Decode(&parsed) + if !parsed.Ok { + slog.Warn("slack post non-ok", "error", parsed.Error) + return "", false + } + return parsed.TS, true +} +func buildReceiptBlocks(order *models.Order) []map[string]any { 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{ @@ -45,7 +57,6 @@ func SlackOrderPaid(order *models.Order) { "text": map[string]any{"type": "mrkdwn", "text": line}, }) } - blocks = append(blocks, map[string]any{"type": "divider"}, map[string]any{ @@ -82,51 +93,66 @@ func SlackOrderPaid(order *models.Order) { }, }, ) - - body, _ := json.Marshal(map[string]any{ - "channel": channel, - "text": fallback, - "blocks": blocks, - }) - - 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", order.ID) - return - } - defer resp.Body.Close() - - var parsed struct { - Ok bool `json:"ok"` - Error string `json:"error"` - } - _ = json.NewDecoder(resp.Body).Decode(&parsed) - if !parsed.Ok { - slog.Warn("slack notify non-ok", "error", parsed.Error, "order_id", order.ID) - } + return blocks } -func SlackPostToResponseURL(responseURL, text string) { - if responseURL == "" { +// SlackOrderPaid posts a compact parent message ("Order #N paid - $X.XX") to +// the orders channel, persists the resulting ts onto the order, and posts the +// full receipt as the first thread reply. All subsequent order events +// (label-buy, tracking updates) should be posted as further thread replies +// via SlackPostToOrderThread. 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 } - body, _ := json.Marshal(map[string]any{ - "replace_original": false, - "text": text, + + parentText := fmt.Sprintf(":coffee: Order #%d paid - %s", order.ID, dollars(order.Total)) + ts, ok := postSlackMessage(token, map[string]any{ + "channel": channel, + "text": parentText, }) - resp, err := slackClient.Post(responseURL, "application/json", bytes.NewReader(body)) - if err != nil { - slog.Warn("slack response_url post failed", "error", err) + if !ok { return } - defer resp.Body.Close() - if resp.StatusCode >= 300 { - slog.Warn("slack response_url non-2xx", "status", resp.StatusCode) + + if err := database.GetDB().Model(&models.Order{}).Where("id = ?", order.ID).Update("slack_thread_ts", ts).Error; err != nil { + slog.Warn("failed to save slack thread ts", "order_id", order.ID, "error", err) } + + _, _ = postSlackMessage(token, map[string]any{ + "channel": channel, + "thread_ts": ts, + "text": parentText, + "blocks": buildReceiptBlocks(order), + }) +} + +// SlackPostToOrderThread posts a plain-text reply inside the order's thread. +// Looks up the thread ts from the database. Silent no-op if the order has no +// saved thread (Slack was disabled when it was created, or post failed earlier). +func SlackPostToOrderThread(orderID uint, text string) { + token := os.Getenv("SLACK_BOT_TOKEN") + channel := os.Getenv("SLACK_CHANNEL") + if token == "" || channel == "" { + return + } + + var order models.Order + if err := database.GetDB().Select("id", "slack_thread_ts").Where("id = ?", orderID).First(&order).Error; err != nil { + slog.Warn("failed to look up order for thread post", "order_id", orderID, "error", err) + return + } + if order.SlackThreadTS == nil || *order.SlackThreadTS == "" { + return + } + + _, _ = postSlackMessage(token, map[string]any{ + "channel": channel, + "thread_ts": *order.SlackThreadTS, + "text": text, + }) } var _ = time.Second