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
Some checks failed
Deploy / deploy (push) Has been cancelled
Some checks failed
Deploy / deploy (push) Has been cancelled
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()...)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user