Compare commits

..

7 Commits

Author SHA1 Message Date
9797bc59d1 feat(notify): daily slack reminder for paid orders awaiting labels
so the ain was that older paid orders were getting buried in the #orders
channel in slack and i would just forget about them. Pin/unpin from
earlier helped, but if i missed pinning one or the channel got noisy, it
was still easy to lose track.

this adds a 24h cron that queries any paid order older than 24h with no
label yet and dumps them all into one #orders message. Each line shows
the order id, how old it is, total, where it is going, and a permalink
back to the orders slack thread so i can hop in and hit Buy Label

Ports the idea from terminal.shops cron/unshipped.ts but out stuch state
is different( we auto-promote paid->shipped on label-buy so
PRE_TRANSIT-stuck can not happen), so the query is "paid + no tracking
number + >24h" instead.

NO orders get a "Bring blocked" tag since i dont have the norwegian org
set up yet and can not actually ship those today. Also fires once 40s
after startup so a deploy does not black out the reminder for a full
day.
2026-05-25 10:59:21 +02:00
48d65c48fa feat(tui): render customer-facing event timeline in order detail veiew (#7)
Pulls events off the new order_events table and shows them as a timeline
  block in the order detail view: Paid, Label created, In transit,
  Delivered (etc) with timestamps. Filters out ops-only events like
  payment_critical, label_orphaned, and tracking_marked_manually so the
  customer never sees internal plumbing.

  DisplayLabel on OrderEvent decides what's customer-visible and what's
  redacted, so the same order_events rows can later power an admin view
  with no filter.
2026-05-24 20:14:14 +02:00
c78021f520 docs(comments): add some comments (#6) 2026-05-24 18:57:58 +02:00
c429c1966e docs(comments): add some comments (#5)
this is the subject of the commit
2026-05-24 18:44:25 +02:00
50e1ba32f3 docs(comments): add some comments (#4) 2026-05-24 12:26:34 +02:00
85b0c53c0a docs(comments): cleanup comments (#3) 2026-05-24 12:02:50 +02:00
366d80a9ee test(dillydallying): testing the ci workflow (#2) 2026-05-23 22:12:46 +02:00
9 changed files with 167 additions and 9 deletions

View File

@@ -180,7 +180,7 @@ func (h *CardHandler) CollectCard(w http.ResponseWriter, r *http.Request) {
// SaveCardRequest holds the raw card fields submitted by the TUI.
// Tokenization happens server-side so the Stripe secret key never
// touches the SSH client or TUI layer..
// touches the SSH client or TUI layer.
type SaveCardRequest struct {
Token string `json:"token"`
}

View File

@@ -5,11 +5,12 @@ import (
"net/http"
"os"
"strconv"
"time"
"terminalShop/pkg/bring"
"terminalShop/pkg/notify"
"terminalShop/pkg/shipping"
"terminalShop/pkg/shippo"
"time"
"terminalShop/api/middleware"
"terminalShop/pkg/audit"
@@ -20,6 +21,7 @@ import (
"github.com/go-chi/chi/v5"
"github.com/stripe/stripe-go/v78"
"github.com/stripe/stripe-go/v78/refund"
"gorm.io/gorm"
)
type OrderHandler struct {
@@ -43,7 +45,10 @@ func (h *OrderHandler) GetOrders(w http.ResponseWriter, r *http.Request) {
userID := middleware.UserIDFromContext(r.Context())
var orders []models.Order
db.Where("user_id = ?", userID).Preload("Items").Order("created_at desc").Find(&orders)
db.Where("user_id = ?", userID).
Preload("Items").
Preload("Events", func(tx *gorm.DB) *gorm.DB { return tx.Order("created_at ASC") }).
Order("created_at desc").Find(&orders)
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
"orders": orders,

View File

@@ -7,6 +7,7 @@ import (
"terminalShop/pkg/database"
"terminalShop/pkg/models"
"terminalShop/pkg/notify"
"github.com/stripe/stripe-go/v78"
"github.com/stripe/stripe-go/v78/paymentintent"
@@ -75,3 +76,26 @@ func ReconcileOrders(stripeKey string) {
}
}
}
// ReconcileUnshipped finds paid order orlder than 24h with no tracking
// number and posts single Slack reminder. Pure read + notif
func ReconcileUnshipped() {
db := database.GetDB()
var orders []models.Order
cutoff := time.Now().Add(-24 * time.Hour)
err := db.Where(
"status = ? AND (tracking_number = '' OR tracking_number IS NULL) AND created_at < ?",
models.OrderStatusPaid, cutoff,
).Order("created_at ASC").Find(&orders).Error
if err != nil {
reconcileLog().Error("unshipped query failed", "error", err)
return
}
if len(orders) == 0 {
return
}
reconcileLog().Info("unshipped orders found", "count", len(orders))
notify.SlackUnshippedReminder(orders)
}

View File

@@ -7,6 +7,8 @@ import (
"terminalShop/pkg/database"
"terminalShop/pkg/models"
"terminalShop/pkg/utils"
"gorm.io/gorm"
)
type ViewHandler struct{}
@@ -38,7 +40,10 @@ func (h *ViewHandler) GetViewInit(w http.ResponseWriter, r *http.Request) {
db.Where("user_id = ?", userID).Find(&cards)
var orders []models.Order
db.Where("user_id = ?", userID).Preload("Items").Order("created_at DESC").Find(&orders)
db.Where("user_id = ?", userID).
Preload("Items").
Preload("Events", func(tx *gorm.DB) *gorm.DB { return tx.Order("created_at ASC") }).
Order("created_at DESC").Find(&orders)
var cartItems []models.CartItem
if cart.ID != 0 {
db.Where("cart_id = ? AND quantity > 0", cart.ID).Preload("Coffee").Find(&cartItems)

View File

@@ -113,6 +113,21 @@ func main() {
}
}()
go func() {
time.Sleep(30 * time.Second)
handlers.ReconcileUnshipped()
ticker := time.NewTicker(24 * time.Hour)
defer ticker.Stop()
for {
select {
case <-ticker.C:
handlers.ReconcileUnshipped()
case <-ctx.Done():
return
}
}
}()
log.Println("API server started. Press Ctrl+C to shutdown.")
// Block until signal received

View File

@@ -71,9 +71,10 @@ type Order struct {
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` // GORM soft-delete sentinel
// Associations
User User `gorm:"foreignKey:UserID" json:"-"`
Card Card `gorm:"foreignKey:CardID" json:"-"`
Items []OrderItem `gorm:"foreignKey:OrderID" json:"items"`
User User `gorm:"foreignKey:UserID" json:"-"`
Card Card `gorm:"foreignKey:CardID" json:"-"`
Items []OrderItem `gorm:"foreignKey:OrderID" json:"items"`
Events []OrderEvent `gorm:"foreignKey:OrderID" json:"events,omitempty"`
}
// TableName specifies the table name for the Order model.

View File

@@ -1,6 +1,9 @@
package models
import "time"
import (
"encoding/json"
"time"
)
type OrderEvent struct {
ID uint `gorm:"primaryKey" json:"id"`
@@ -14,3 +17,47 @@ type OrderEvent struct {
func (OrderEvent) TableName() string {
return "order_events"
}
func (e *OrderEvent) PayloadMap() map[string]any {
if e.Payload == "" {
return nil
}
var m map[string]any
if err := json.Unmarshal([]byte(e.Payload), &m); err != nil {
return nil
}
return m
}
func (e *OrderEvent) DisplayLabel() (string, bool) {
switch e.Type {
case "order_paid":
return "Paid", true
case "label_purchased":
return "Label created", true
case "order_delivered":
return "Delivered", true
case "order_refunded":
return "Refunded", true
case "order_failed":
return "Payment failed", true
case "tracking_updated":
status, _ := e.PayloadMap()["status"].(string)
return trackingLabel(status)
}
return "", false
}
func trackingLabel(status string) (string, bool) {
switch status {
case "transit":
return "In transit", true
case "failure":
return "Delivery issue", true
case "returned":
return "Returned to sender", true
case "pre_transit":
return "Label scanned by carrier", true
}
return "", false
}

View File

@@ -1,3 +1,4 @@
// Package notify provides a simple interface to send notifications to Slack.
package notify
import (
@@ -14,8 +15,10 @@ import (
"terminalShop/pkg/models"
)
// slack client
var slackClient = &http.Client{Timeout: 5 * time.Second}
// dollars converts cents to dollars
func dollars(cents int) string {
return fmt.Sprintf("$%.2f", float64(cents)/100)
}
@@ -261,4 +264,44 @@ func markStatusActionBlock(orderID uint) map[string]any {
}
}
var _ = time.Second
func SlackUnshippedReminder(orders []models.Order) {
token := os.Getenv("SLACK_BOT_TOKEN")
channel := os.Getenv("SLACK_CHANNEL")
if token == "" || channel == "" || len(orders) == 0 {
return
}
workspace := os.Getenv("SLACK_WORKSPACE_SUBDOMAIN")
lines := []string{fmt.Sprintf(":hourglass_flowing_sand: %d order(s) awaiting label", len(orders))}
for _, order := range orders {
lines = append(lines, " "+unshippedOrderLine(order, channel, workspace))
}
_, _ = postSlackMessage(token, map[string]any{
"channel": channel,
"text": strings.Join(lines, "\n"),
})
}
func unshippedOrderLine(order models.Order, channel, workspace string) string {
age := time.Since(order.CreatedAt).Round(time.Hour)
location := order.ShippingCountry
if order.ShippingCity != "" {
location = order.ShippingCity + ", " + order.ShippingCountry
}
label := fmt.Sprintf("Order #%d", order.ID)
if order.SlackThreadTS != nil && *order.SlackThreadTS != "" && workspace != "" {
ts := strings.ReplaceAll(*order.SlackThreadTS, ".", "")
permalink := fmt.Sprintf("https://%s.slack.com/archives/%s/p%s", workspace, channel, ts)
label = fmt.Sprintf("<%s|Order #%d>", permalink, order.ID)
}
tag := ""
if order.ShippingCountry == "NO" {
tag = " _(NO * Bring blocked)_"
}
return fmt.Sprintf("%s * %s old * %s * %s%s", label, age, dollars(order.Total), location, tag)
}

View File

@@ -201,6 +201,24 @@ func (m Model) buildOrderDetailView(order models.Order, _ int) string {
}
}
visible := make([]models.OrderEvent, 0, len(order.Events))
for _, event := range order.Events {
if _, ok := event.DisplayLabel(); ok {
visible = append(visible, event)
}
}
if len(visible) > 0 {
b.WriteString(labelStyle.Render("Timeline"))
b.WriteString("\n")
for _, event := range visible {
label, _ := event.DisplayLabel()
when := event.CreatedAt.Format("Jan 02 3:04 PM")
b.WriteString(valueStyle.Render(fmt.Sprintf(" %-20s %s", label, dimStyle.Render(when))))
b.WriteString("\n")
}
b.WriteString("\n")
}
// Shipping address
b.WriteString(labelStyle.Render("Ship To"))
b.WriteString("\n")