renewal-unlock: orders.renews_at lazy expiry + owner unlock notices (ntfy/sms channels, exempt from metering) — verified

This commit is contained in:
2026-08-10 10:10:50 +10:00
parent 8b5db410a3
commit 9d43403f6f
8 changed files with 141 additions and 33 deletions

View File

@@ -0,0 +1,91 @@
// 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)
}
}