feat(slack): add slack mark status buttons for bring orders, post-label thread now block kit, synthesize shippo webhook over loopback to reuse state-date guard and delivered transition

This commit is contained in:
2026-05-20 18:09:34 +02:00
parent 9d3ac9761d
commit 96e76f7e5b
4 changed files with 188 additions and 22 deletions

View File

@@ -1,6 +1,7 @@
package handlers
import (
"bytes"
"context"
"crypto/hmac"
"crypto/sha256"
@@ -13,6 +14,10 @@ import (
"net/url"
"os"
"strconv"
"strings"
"terminalShop/pkg/audit"
"terminalShop/pkg/database"
"terminalShop/pkg/models"
"terminalShop/pkg/notify"
"terminalShop/pkg/utils"
"time"
@@ -22,14 +27,16 @@ type SlackHandler struct {
signingSecret string
adminKey string
apiPort string
shippoSecret string
httpClient *http.Client
}
func NewSlackHandler(signingSecret, adminKey, apiPort string) *SlackHandler {
func NewSlackHandler(signingSecret, adminKey, apiPort, shippoWebhookSecret string) *SlackHandler {
return &SlackHandler{
signingSecret: signingSecret,
adminKey: adminKey,
apiPort: apiPort,
shippoSecret: shippoWebhookSecret,
httpClient: &http.Client{Timeout: 30 * time.Second},
}
}
@@ -98,6 +105,12 @@ func (h *SlackHandler) dispatch(action slackAction, p slackInteractivePayload) {
switch action.ActionID {
case "buy_label":
h.handleBuyLabel(action.Value, p)
case "mark_pre_transit":
h.handleMarkStatus(action.Value, p, "PRE_TRANSIT")
case "mark_in_transit":
h.handleMarkStatus(action.Value, p, "TRANSIT")
case "mark_delivered":
h.handleMarkStatus(action.Value, p, "DELIVERED")
default:
slog.Warn("slack interactivity: unknown action", "action_id", action.ActionID)
}
@@ -142,13 +155,97 @@ func (h *SlackHandler) handleBuyLabel(orderIDStr string, p slackInteractivePaylo
return
}
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.SlackPostLabelPurchased(orderID, result.Data.LabelURL, result.Data.CostCents)
}
func (h *SlackHandler) handleMarkStatus(orderIDStr string, p slackInteractivePayload, status string) {
orderID64, err := strconv.ParseUint(orderIDStr, 10, 32)
if err != nil {
slog.Warn("slack mark_status: invalid order id", "value", orderIDStr)
return
}
orderID := uint(orderID64)
var order models.Order
if err := database.GetDB().Select("id", "tracking_number", "carrier").Where("id = ?", orderID).First(&order).Error; err != nil {
slog.Warn("slack mark_status: order lookup failed", "order_id", orderID, "error", err)
return
}
if order.TrackingNumber == "" {
notify.SlackPostToOrderThread(orderID, ":x: Cannot mark status, the order has no tracking number yet.")
return
}
detail := defaultDetailFor(status)
payload := map[string]any{
"event": "track_updated",
"data": map[string]any{
"tracking_number": order.TrackingNumber,
"carrier": strings.ToLower(order.Carrier),
"tracking_status": map[string]any{
"status": status,
"status_date": time.Now().UTC().Format(time.RFC3339),
"status_details": detail,
},
},
}
body, _ := json.Marshal(payload)
webhookURL := fmt.Sprintf("http://localhost:%s/api/v1/webhooks/shippo?token=%s", h.apiPort, url.QueryEscape(h.shippoSecret))
req, _ := http.NewRequestWithContext(context.Background(), http.MethodPost, webhookURL, bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := h.httpClient.Do(req)
if err != nil {
notify.SlackPostToOrderThread(orderID, fmt.Sprintf(":x: Mark %s failed: %v", status, err))
return
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
notify.SlackPostToOrderThread(orderID, fmt.Sprintf(":x: Mark %s failed (HTTP %d)", status, resp.StatusCode))
return
}
audit.TrackingMarkedManually(orderID, status, p.User.Username)
h.editClearActions(p.ResponseURL, status, p.User.Username)
}
func defaultDetailFor(status string) string {
switch status {
case "PRE_TRANSIT":
return "Marked pre-transit by operator"
case "TRANSIT":
return "Marked transit by operator"
case "DELIVERED":
return "Marked delivered by operator"
}
return "Marked by operator"
}
func (h *SlackHandler) editClearActions(responseURL, status, actor string) {
if responseURL == "" {
return
}
stamp := fmt.Sprintf(":white_check_mark: Marked %s by @%s at %s", strings.ToLower(status), actor, time.Now().UTC().Format("15:04 MST"))
body, _ := json.Marshal(map[string]any{
"replace_original": true,
"text": stamp,
"blocks": []map[string]any{
{"type": "context", "elements": []map[string]any{
{"type": "mrkdwn", "text": stamp},
}},
},
})
req, _ := http.NewRequestWithContext(context.Background(), http.MethodPost, responseURL, bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := h.httpClient.Do(req)
if err != nil {
slog.Warn("slack mark_status: response_url edit failed", "error", err)
return
}
resp.Body.Close()
}
func (h *SlackHandler) verifySignature(r *http.Request, body []byte) bool {
@@ -180,4 +277,4 @@ func abs(x int64) int64 {
return x
}
var _ = os.Getenv
var _ = os.Getenv

View File

@@ -52,7 +52,7 @@ func SetupRoutes(
addressHandler := handlers.NewAddressHandler(shippoAPIKey, bringAPIUID, bringAPIKey)
viewHandler := handlers.NewViewHandler()
webhookHandler := handlers.NewWebhookHandler(stripeWebhookSecret, stripeSecretKey, shippoWebhookSecret)
slackHandler := handlers.NewSlackHandler(slackSigningSecret, adminAPIKey, apiPort)
slackHandler := handlers.NewSlackHandler(slackSigningSecret, adminAPIKey, apiPort, shippoWebhookSecret)
// Short payment redirect and success page — no auth required.
r.Get("/pay/{token}", handlers.PayRedirect)

View File

@@ -10,18 +10,19 @@ import (
type eventType string
const (
eventCardAdded eventType = "card_added"
eventCardDeleted eventType = "card_deleted"
eventOrderCreated eventType = "order_created"
eventOrderPaid eventType = "order_paid"
eventOrderShipped eventType = "order_shipped"
eventOrderFailed eventType = "order_failed"
eventOrderRefunded eventType = "order_refunded"
eventPaymentCritical eventType = "payment_critical"
eventLabelPurchased eventType = "label_purchased"
eventLabelOrphaned eventType = "label_orphaned"
eventTrackingUpdated eventType = "tracking_updated"
eventOrderDelivered eventType = "order_delivered"
eventCardAdded eventType = "card_added"
eventCardDeleted eventType = "card_deleted"
eventOrderCreated eventType = "order_created"
eventOrderPaid eventType = "order_paid"
eventOrderShipped eventType = "order_shipped"
eventOrderFailed eventType = "order_failed"
eventOrderRefunded eventType = "order_refunded"
eventPaymentCritical eventType = "payment_critical"
eventLabelPurchased eventType = "label_purchased"
eventLabelOrphaned eventType = "label_orphaned"
eventTrackingUpdated eventType = "tracking_updated"
eventOrderDelivered eventType = "order_delivered"
eventTrackingMarkedManually eventType = "tracking_marked_manually"
)
type event struct {
@@ -37,6 +38,7 @@ type event struct {
Tracking string
Status string
Error string
Actor string
}
func (e event) attrs() []any {
@@ -74,6 +76,9 @@ func (e event) attrs() []any {
if e.Error != "" {
a = append(a, "error", e.Error)
}
if e.Actor != "" {
a = append(a, "actor", e.Actor)
}
return a
}
@@ -201,3 +206,15 @@ func OrderDelivered(orderID uint, trackingNumber string) {
Tracking: trackingNumber,
}.attrs()...)
}
// TrackingMarkedManually records operator made tracking status mark
// from the slack interactivity surface. Actor is the slack username that
// clicked the button
func TrackingMarkedManually(orderID uint, status, actor string) {
slog.Info("audit", event{
Event: eventTrackingMarkedManually,
OrderID: orderID,
Status: status,
Actor: actor,
}.attrs()...)
}

View File

@@ -7,6 +7,7 @@ import (
"log/slog"
"net/http"
"os"
"strings"
"time"
"terminalShop/pkg/database"
@@ -209,4 +210,55 @@ func SlackPostToOrderThread(orderID uint, text string) {
})
}
func SlackPostLabelPurchased(orderID uint, labelURL string, costCents int) {
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", "carrier", "tracking_number", "tracking_url", "slack_thread_ts").
Where("id = ?", orderID).First(&order).Error; err != nil {
slog.Warn("failed to look up order for label-purchased slack post", "order_id", orderID, "error", err)
return
}
if order.SlackThreadTS == nil || *order.SlackThreadTS == "" {
return
}
headLine := fmt.Sprintf(":white_check_mark: Labeled - %s `%s` (%s). <%s|Download label PDF>", order.Carrier, order.TrackingNumber, dollars(costCents), labelURL)
blocks := []map[string]any{
{
"type": "section",
"text": map[string]any{"type": "mrkdwn", "text": headLine},
},
}
if strings.EqualFold(order.Carrier, "BRING") {
blocks = append(blocks, markStatusActionBlock(order.ID))
}
_, _ = postSlackMessage(token, map[string]any{
"channel": channel,
"thread_ts": *order.SlackThreadTS,
"text": headLine,
"blocks": blocks,
})
}
func markStatusActionBlock(orderID uint) map[string]any {
id := fmt.Sprintf("%d", orderID)
return map[string]any{
"type": "actions",
"elements": []map[string]any{
{"type": "button", "text": map[string]any{"type": "plain_text", "text": "Mark Pre-Transit", "emoji": true}, "value": id, "action_id": "mark_pre_transit"},
{"type": "button", "text": map[string]any{"type": "plain_text", "text": "Mark In Transit", "emoji": true}, "value": id, "action_id": "mark_in_transit", "style": "primary"},
{"type": "button", "text": map[string]any{"type": "plain_text", "text": "Mark Delivered", "emoji": true}, "value": id, "action_id": "mark_delivered"},
},
}
}
var _ = time.Second