// Package notify sends owner notifications (unlock/renewal notices). // Channels: ntfy (self-hosted, default), SMS (via the sms.Sender), or both, // selected by NOTIFY_CHANNEL=ntfy|sms|both. package notify import ( "bytes" "fmt" "log" "net/http" "os" "time" "wherewoof/frontend/internal/sms" ) // Notifier delivers messages to the owner. type Notifier struct { sender sms.Sender // used when the SMS channel is enabled client *http.Client } // New builds a Notifier. sender may be nil (SMS channel disabled). func New(sender sms.Sender) *Notifier { return &Notifier{sender: sender, client: &http.Client{Timeout: 10 * time.Second}} } // Channel returns the configured channel: ntfy (default), sms, or both. func (n *Notifier) Channel() string { c := os.Getenv("NOTIFY_CHANNEL") if c != "sms" && c != "both" { return "ntfy" } return c } // Send delivers the message to the owner's phone (SMS) and/or the ntfy topic. // Unlock notices are system messages — they are NOT subject to SMS metering. func (n *Notifier) Send(ownerPhone, message string) { ch := n.Channel() if ch == "sms" || ch == "both" { if n.sender != nil { if err := n.sender.Send(sms.NormalizeAU(ownerPhone), message); err != nil { log.Println("notify sms failed:", err) } } else { log.Println("notify: SMS channel selected but no sender configured") } } if ch == "ntfy" || ch == "both" { n.sendNtfy(message) } } func (n *Notifier) sendNtfy(message string) { url := os.Getenv("NTFY_URL") if url == "" { url = "https://ntfy.sh" } topic := os.Getenv("NTFY_TOPIC") if topic == "" { topic = "wherewoof-owner-alerts" } req, err := http.NewRequest(http.MethodPost, url+"/"+topic, bytes.NewBufferString(message)) if err != nil { log.Println("notify ntfy:", err) return } req.Header.Set("Title", "Where Woof") resp, err := n.client.Do(req) if err != nil { log.Println("notify ntfy failed:", err) return } resp.Body.Close() if resp.StatusCode >= 300 { log.Printf("notify ntfy: status %d", resp.StatusCode) } } // UnlockMessage builds the owner notice for a gated scan. func UnlockMessage(tagCode, reason string) string { switch reason { case "lapsed": return fmt.Sprintf("Where Woof: your tag %s was scanned, but your plan is lapsed. Renew to see the finder's details: https://where-woof.com/account", tagCode) case "credits": return fmt.Sprintf("Where Woof: your tag %s was scanned, but you're out of SMS credits. Top up or renew to see the finder's details: https://where-woof.com/account", tagCode) default: return fmt.Sprintf("Where Woof: your tag %s was scanned. Manage your plan: https://where-woof.com/account", tagCode) } }