diff --git a/api/handlers/orders.go b/api/handlers/orders.go index 74c8748..1c79114 100644 --- a/api/handlers/orders.go +++ b/api/handlers/orders.go @@ -114,7 +114,7 @@ func (h *OrderHandler) CreateRefundRequest(w http.ResponseWriter, r *http.Reques text += "\n\n" + quoted } - if err := notify.SlackPostToOrderThread(order.ID, text); err != nil { + if err := notify.SlackPostToOrderThreadBroadcast(order.ID, text); err != nil { utils.RespondError(w, http.StatusBadGateway, "SLACK_ERROR", "failed to send refund request", nil) return } diff --git a/pkg/models/order_display.go b/pkg/models/order_display.go index 5a21f9e..b7ddb5e 100644 --- a/pkg/models/order_display.go +++ b/pkg/models/order_display.go @@ -11,10 +11,27 @@ const ( DisplayKindBrand DisplayKind = "brand" // shipped / in transit DisplayKindSuccess DisplayKind = "success" // delivered DisplayKindError DisplayKind = "error" // cancelled, failed, refunded - DisplayKindWarning DisplayKind = "warning" // refund pending, delivery issue + DisplayKindWarning DisplayKind = "warning" // delivery issue, returned + DisplayKindRefund DisplayKind = "refund" // refund requested, awaiting review ) +// refundPending reports whether the order has an open customer refund request +// that has not yet been resolved by a terminal status change. +func (order *Order) refundPending() bool { + if order.LastRefundRequestAt == nil { + return false + } + switch order.Status { + case OrderStatusRefunded, OrderStatusCancelled, OrderStatusFailed: + return false + } + return true +} + func (order *Order) DisplayState() string { + if order.refundPending() { + return "Refund Requested" + } switch order.Status { case OrderStatusPending: return "Awaiting payment" @@ -54,6 +71,9 @@ func (order *Order) DisplayState() string { } func (order *Order) DisplayKind() DisplayKind { + if order.refundPending() { + return DisplayKindRefund + } switch order.Status { case OrderStatusPending: return DisplayKindNeutral diff --git a/pkg/models/order_display_test.go b/pkg/models/order_display_test.go index d81d63e..6321292 100644 --- a/pkg/models/order_display_test.go +++ b/pkg/models/order_display_test.go @@ -1,8 +1,12 @@ package models -import "testing" +import ( + "testing" + "time" +) func TestOrderDisplayState(t *testing.T) { + now := time.Now() tests := []struct { name string order Order @@ -23,6 +27,10 @@ func TestOrderDisplayState(t *testing.T) { {"cancelled", Order{Status: OrderStatusCancelled}, "Cancelled", DisplayKindError}, {"refunded", Order{Status: OrderStatusRefunded}, "Refunded", DisplayKindError}, {"failed", Order{Status: OrderStatusFailed}, "Payment failed", DisplayKindError}, + {"paid with open refund request", Order{Status: OrderStatusPaid, LastRefundRequestAt: &now}, "Refund Requested", DisplayKindRefund}, + {"delivered with open refund request", Order{Status: OrderStatusDelivered, LastRefundRequestAt: &now}, "Refund Requested", DisplayKindRefund}, + {"refunded order ignores prior request", Order{Status: OrderStatusRefunded, LastRefundRequestAt: &now}, "Refunded", DisplayKindError}, + {"cancelled order ignores prior request", Order{Status: OrderStatusCancelled, LastRefundRequestAt: &now}, "Cancelled", DisplayKindError}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { diff --git a/pkg/notify/slack.go b/pkg/notify/slack.go index b94d579..86677fc 100644 --- a/pkg/notify/slack.go +++ b/pkg/notify/slack.go @@ -191,6 +191,17 @@ func SlackUnpinOrder(orderID uint) { // It looks up the thread ts from the database and returns an error when the // reply cannot be posted. func SlackPostToOrderThread(orderID uint, text string) error { + return slackPostToOrderThread(orderID, text, false) +} + +// SlackPostToOrderThreadBroadcast posts a thread reply that is also surfaced +// to the channel (reply_broadcast=true) so channel members get a normal +// notification. Use for messages that need immediate operator attention. +func SlackPostToOrderThreadBroadcast(orderID uint, text string) error { + return slackPostToOrderThread(orderID, text, true) +} + +func slackPostToOrderThread(orderID uint, text string, broadcast bool) error { token := os.Getenv("SLACK_BOT_TOKEN") channel := os.Getenv("SLACK_CHANNEL") if token == "" || channel == "" { @@ -206,11 +217,16 @@ func SlackPostToOrderThread(orderID uint, text string) error { return fmt.Errorf("order has no slack thread") } - if _, ok := postSlackMessage(token, map[string]any{ + payload := map[string]any{ "channel": channel, "thread_ts": *order.SlackThreadTS, "text": text, - }); !ok { + } + if broadcast { + payload["reply_broadcast"] = true + } + + if _, ok := postSlackMessage(token, payload); !ok { slog.Warn("failed to post slack thread reply", "order_id", orderID) return fmt.Errorf("failed to post slack thread reply") } diff --git a/pkg/tui/account_orders.go b/pkg/tui/account_orders.go index dd3b93f..280ad5b 100644 --- a/pkg/tui/account_orders.go +++ b/pkg/tui/account_orders.go @@ -278,10 +278,9 @@ func (m Model) displayStyle(k models.DisplayKind) lipgloss.Style { case models.DisplayKindError: return m.theme.TextError() case models.DisplayKindWarning: - // !TODO add refunds and change this - // No warning color in theme yet, reuse loading pink as visual flag - // for now return m.theme.TextLoading() + case models.DisplayKindRefund: + return m.theme.TextBrand() } return m.theme.TextHighlight() } diff --git a/pkg/tui/refund.go b/pkg/tui/refund.go index d5a8550..ad827a5 100644 --- a/pkg/tui/refund.go +++ b/pkg/tui/refund.go @@ -164,10 +164,8 @@ func (m Model) newRefundTextarea() textarea.Model { ta.Placeholder = "Describe the issue (optional)..." ta.ShowLineNumbers = true ta.CharLimit = 65536 - ta.DynamicHeight = true - ta.MinHeight = refundMinTextareaRows - ta.MaxHeight = max(refundMinTextareaRows, min(refundMaxTextareaRows, m.heightContainer-refundModalChrome)) - ta.SetHeight(refundMinTextareaRows) + ta.DynamicHeight = false + ta.SetHeight(m.refundTextareaHeight()) ta.SetWidth(m.refundTextareaWidth()) base := lipgloss.NewStyle() @@ -200,8 +198,7 @@ func (m Model) newRefundTextarea() textarea.Model { } func (m Model) resizeRefundTextarea(state *refundState) { - state.textarea.MinHeight = refundMinTextareaRows - state.textarea.MaxHeight = max(refundMinTextareaRows, min(refundMaxTextareaRows, m.heightContainer-refundModalChrome)) + state.textarea.SetHeight(m.refundTextareaHeight()) state.textarea.SetWidth(m.refundTextareaWidth()) } @@ -323,22 +320,26 @@ func (m Model) renderRefundTextarea(state *refundState) string { border = m.theme.Highlight() } + wrapper := lipgloss.NewStyle(). + Border(lipgloss.NormalBorder()). + BorderForeground(border) + + contentWidth := m.refundContentWidth() + state.textarea.SetWidth(contentWidth - wrapper.GetHorizontalFrameSize()) + state.textarea.SetHeight(m.refundTextareaHeight()) + + textareaBox := wrapper.Render(state.textarea.View()) + counter := m.theme.TextDim(). - Width(m.refundTextareaWidth()). + Width(contentWidth). Align(lipgloss.Right). Render(fmt.Sprintf("%d chars", state.textarea.Length())) - box := lipgloss.NewStyle(). - Border(lipgloss.NormalBorder()). - BorderForeground(border). - Padding(0, 1). - Width(m.refundContentWidth()). - Render(state.textarea.View() + "\n" + counter) - out := lipgloss.JoinVertical( lipgloss.Left, m.theme.TextLabel().Render(label), - box, + textareaBox, + counter, ) if state.messageErr != "" { out += "\n" + m.theme.TextError().Render(state.messageErr) @@ -367,3 +368,7 @@ func (m Model) refundContentWidth() int { func (m Model) refundTextareaWidth() int { return max(1, m.refundContentWidth()-refundTextareaHPad) } + +func (m Model) refundTextareaHeight() int { + return max(refundMinTextareaRows, min(refundMaxTextareaRows, m.heightContainer-refundModalChrome)) +}