diff --git a/api/handlers/slack.go b/api/handlers/slack.go index 9b09780..9ba937b 100644 --- a/api/handlers/slack.go +++ b/api/handlers/slack.go @@ -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 \ No newline at end of file +var _ = os.Getenv diff --git a/api/routes/routes.go b/api/routes/routes.go index c162203..973b521 100644 --- a/api/routes/routes.go +++ b/api/routes/routes.go @@ -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) diff --git a/pkg/audit/audit.go b/pkg/audit/audit.go index c2412ca..e4fe176 100644 --- a/pkg/audit/audit.go +++ b/pkg/audit/audit.go @@ -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()...) +} diff --git a/pkg/notify/slack.go b/pkg/notify/slack.go index d008519..2e9c364 100644 --- a/pkg/notify/slack.go +++ b/pkg/notify/slack.go @@ -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