From 9d43403f6f3729d7d1453d9ed01c27e5c7bb9360 Mon Sep 17 00:00:00 2001 From: Sam Rolfe Date: Mon, 10 Aug 2026 10:10:50 +1000 Subject: [PATCH] =?UTF-8?q?renewal-unlock:=20orders.renews=5Fat=20lazy=20e?= =?UTF-8?q?xpiry=20+=20owner=20unlock=20notices=20(ntfy/sms=20channels,=20?= =?UTF-8?q?exempt=20from=20metering)=20=E2=80=94=20verified?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- db/schema.sql | 3 + frontend/internal/db/models.go | 1 + frontend/internal/db/queries.sql.go | 3 +- frontend/internal/handlers/handlers.go | 16 +++-- frontend/internal/handlers/scan.go | 43 ++++++----- frontend/internal/notify/notify.go | 91 ++++++++++++++++++++++++ frontend/main.go | 3 +- openspec/changes/renewal-unlock/tasks.md | 14 ++-- 8 files changed, 141 insertions(+), 33 deletions(-) create mode 100644 frontend/internal/notify/notify.go diff --git a/db/schema.sql b/db/schema.sql index 089a51f..d1b0685 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -90,3 +90,6 @@ CREATE TABLE IF NOT EXISTS url_shortened ( -- Phase 5.5 quick wins: customer pause + order amounts (idempotent). ALTER TABLE users ADD COLUMN IF NOT EXISTS paused BOOLEAN NOT NULL DEFAULT FALSE; ALTER TABLE orders ADD COLUMN IF NOT EXISTS amount NUMERIC(10,2) NOT NULL DEFAULT 0; + +-- Renewal tracking (lazy expiry): paid orders past renews_at are treated lapsed. +ALTER TABLE orders ADD COLUMN IF NOT EXISTS renews_at TIMESTAMPTZ; diff --git a/frontend/internal/db/models.go b/frontend/internal/db/models.go index 81e2a3c..7d6363c 100644 --- a/frontend/internal/db/models.go +++ b/frontend/internal/db/models.go @@ -15,6 +15,7 @@ type Order struct { CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` Amount pgtype.Numeric `json:"amount"` + RenewsAt pgtype.Timestamptz `json:"renews_at"` } type Product struct { diff --git a/frontend/internal/db/queries.sql.go b/frontend/internal/db/queries.sql.go index 948e374..6219ae0 100644 --- a/frontend/internal/db/queries.sql.go +++ b/frontend/internal/db/queries.sql.go @@ -222,7 +222,7 @@ func (q *Queries) GetLatestScanByTag(ctx context.Context, tagID int64) (Scan, er } const getOrderByID = `-- name: GetOrderByID :one -SELECT id, account_id, status, created_at, updated_at, amount FROM orders WHERE id = $1 +SELECT id, account_id, status, created_at, updated_at, amount, renews_at FROM orders WHERE id = $1 ` func (q *Queries) GetOrderByID(ctx context.Context, id int64) (Order, error) { @@ -235,6 +235,7 @@ func (q *Queries) GetOrderByID(ctx context.Context, id int64) (Order, error) { &i.CreatedAt, &i.UpdatedAt, &i.Amount, + &i.RenewsAt, ) return i, err } diff --git a/frontend/internal/handlers/handlers.go b/frontend/internal/handlers/handlers.go index 006df8f..92b438c 100644 --- a/frontend/internal/handlers/handlers.go +++ b/frontend/internal/handlers/handlers.go @@ -9,6 +9,7 @@ import ( "wherewoof/frontend/internal/auth" "wherewoof/frontend/internal/db" + "wherewoof/frontend/internal/notify" "wherewoof/frontend/internal/sms" "wherewoof/frontend/internal/storage" ) @@ -20,15 +21,16 @@ type Templates map[string]*template.Template // App holds dependencies shared by all handlers. type App struct { - Queries *db.Queries - Tpl Templates - Sender sms.Sender - Storage *storage.Client + Queries *db.Queries + Tpl Templates + Sender sms.Sender + Storage *storage.Client + Notifier *notify.Notifier } -// New returns an App with the given query layer, template sets, SMS sender, and object storage. -func New(queries *db.Queries, tpl Templates, sender sms.Sender, store *storage.Client) *App { - return &App{Queries: queries, Tpl: tpl, Sender: sender, Storage: store} +// New returns an App with the given dependencies. +func New(queries *db.Queries, tpl Templates, sender sms.Sender, store *storage.Client, ntf *notify.Notifier) *App { + return &App{Queries: queries, Tpl: tpl, Sender: sender, Storage: store, Notifier: ntf} } // PageData is the root data passed to the base layout. diff --git a/frontend/internal/handlers/scan.go b/frontend/internal/handlers/scan.go index a306d15..b3ac3b0 100644 --- a/frontend/internal/handlers/scan.go +++ b/frontend/internal/handlers/scan.go @@ -15,6 +15,7 @@ import ( "github.com/jackc/pgx/v5/pgtype" "wherewoof/frontend/internal/db" + "wherewoof/frontend/internal/notify" "wherewoof/frontend/internal/sms" ) @@ -73,7 +74,7 @@ func (a *App) Scan(w http.ResponseWriter, r *http.Request) { return } - alertSent := a.shouldAlert(r.Context(), tag, scan, hasLoc, req.Lat, req.Lng, ptrStr(req.Phone), clientIP(r)) + alertSent, reason := a.shouldAlert(r.Context(), tag, scan, hasLoc, req.Lat, req.Lng, ptrStr(req.Phone), clientIP(r)) if alertSent { if a.alertOwner(r.Context(), tag, scan, req.Lat, req.Lng) { _ = a.Queries.SetScanAlertSent(r.Context(), db.SetScanAlertSentParams{ID: scan.ID, AlertSent: true}) @@ -82,46 +83,54 @@ func (a *App) Scan(w http.ResponseWriter, r *http.Request) { _ = a.Queries.AddSmsUsed(r.Context(), tag.ID) } } + } else if reason == "lapsed" || reason == "credits" { + // Owner unlock notice (ntfy / optional SMS) — exempt from metering. + if a.Notifier != nil && tag.Phone.Valid { + a.Notifier.Send(tag.Phone.String, notify.UnlockMessage(tag.TagCode, reason)) + } } writeJSON(w, map[string]any{"ok": true, "alert_sent": alertSent}) } // shouldAlert applies the throttle rules and sms_enabled flag. -// Order: sms_enabled → owner phone → 24 h fingerprint block → paid-order gating -// → per-tag daily cap → per-IP hourly rate → 10-min window rules. -func (a *App) shouldAlert(ctx context.Context, tag db.Tag, scan db.Scan, hasLoc bool, lat, lng *float64, phone, ip string) bool { +// Returns (send, reason) where reason is "lapsed" or "credits" when those +// gates blocked the alert (so the owner can be notified to renew/top up). +func (a *App) shouldAlert(ctx context.Context, tag db.Tag, scan db.Scan, hasLoc bool, lat, lng *float64, phone, ip string) (bool, string) { if !tag.SmsEnabled { - return false + return false, "" } if !tag.Phone.Valid { - return false + return false, "" } // Customer pause (admin kill-switch): paused owner -> record only. if tag.OwnerID.Valid { if owner, err := a.Queries.GetUserByID(ctx, tag.OwnerID.Int64); err == nil && owner.Paused { - return false + return false, "" } } // 24 h per-device block: same fingerprint seen recently (excluding this scan) => record only. if fp := scan.Fingerprint.String; fp != "" { if _, err := a.Queries.GetRecentScanByFingerprint(ctx, db.GetRecentScanByFingerprintParams{Fingerprint: pgtype.Text{String: fp, Valid: true}, ID: scan.ID}); err == nil { - return false + return false, "" } } - // Paid-order gating (2014 late-payment model): a linked non-paid order blocks alerts. + // Paid-order gating with lazy expiry: non-paid status, or a paid order + // past its renews_at date, both block and trigger the unlock notice. if tag.OrderID.Valid { - if order, err := a.Queries.GetOrderByID(ctx, tag.OrderID.Int64); err == nil && order.Status != "paid" { - return false + if order, err := a.Queries.GetOrderByID(ctx, tag.OrderID.Int64); err == nil { + if order.Status != "paid" || (order.RenewsAt.Valid && order.RenewsAt.Time.Before(time.Now())) { + return false, "lapsed" + } } } // SMS metering: a positive allocation is required; exhausted credits block alerts. if tag.SmsAllocated > 0 && tag.SmsUsed >= tag.SmsAllocated { - return false + return false, "credits" } // Per-tag daily cap. @@ -129,7 +138,7 @@ func (a *App) shouldAlert(ctx context.Context, tag db.Tag, scan db.Scan, hasLoc TagID: tag.ID, ScannedAt: pgtype.Timestamptz{Time: time.Now().Add(-24 * time.Hour), Valid: true}, }); err == nil && n >= maxAlertsPerTagDay { - return false + return false, "" } // Per-IP hourly rate. @@ -138,13 +147,13 @@ func (a *App) shouldAlert(ctx context.Context, tag db.Tag, scan db.Scan, hasLoc Ip: pgtype.Text{String: ip, Valid: true}, ScannedAt: pgtype.Timestamptz{Time: time.Now().Add(-time.Hour), Valid: true}, }); err == nil && n >= maxAlertsPerIPHour { - return false + return false, "" } } last, err := a.Queries.GetLastAlertByTag(ctx, tag.ID) if err != nil { - return errors.Is(err, pgx.ErrNoRows) // no prior alert -> alert + return errors.Is(err, pgx.ErrNoRows), "" // no prior alert -> alert } // Within the window? @@ -153,9 +162,9 @@ func (a *App) shouldAlert(ctx context.Context, tag db.Tag, scan db.Scan, hasLoc moved := hasLoc && last.Lat.Valid && last.Lng.Valid && sms.HaversineMeters(last.Lat.Float64, last.Lng.Float64, *lat, *lng) > alertMinDistance differentPhone := phone != "" && phone != last.ScannerPhone.String - return moved || differentPhone + return moved || differentPhone, "" } - return true + return true, "" } // clientIP returns the client's IP from X-Forwarded-For (set by Caddy) or RemoteAddr. diff --git a/frontend/internal/notify/notify.go b/frontend/internal/notify/notify.go new file mode 100644 index 0000000..e44b4cb --- /dev/null +++ b/frontend/internal/notify/notify.go @@ -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) + } +} diff --git a/frontend/main.go b/frontend/main.go index 54e75d0..a55f7ab 100644 --- a/frontend/main.go +++ b/frontend/main.go @@ -12,6 +12,7 @@ import ( "wherewoof/frontend/internal/auth" "wherewoof/frontend/internal/db" "wherewoof/frontend/internal/handlers" + "wherewoof/frontend/internal/notify" "wherewoof/frontend/internal/sms" "wherewoof/frontend/internal/storage" ) @@ -60,7 +61,7 @@ func main() { log.Fatal("storage:", err) } - app := handlers.New(db.New(pool), tpl, sender, store) + app := handlers.New(db.New(pool), tpl, sender, store, notify.New(sender)) mux := http.NewServeMux() mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.Dir("static")))) diff --git a/openspec/changes/renewal-unlock/tasks.md b/openspec/changes/renewal-unlock/tasks.md index 1c435b0..0bc658d 100644 --- a/openspec/changes/renewal-unlock/tasks.md +++ b/openspec/changes/renewal-unlock/tasks.md @@ -1,18 +1,18 @@ ## 1. Schema & Notify -- [ ] 1.1 `db/schema.sql`: idempotent `orders.renews_at TIMESTAMPTZ`; `make db-up` -- [ ] 1.2 `internal/notify`: Notifier (ntfy POST + sms via sms.Sender; `NOTIFY_CHANNEL` env: ntfy|sms|both; `NTFY_URL`/`NTFY_TOPIC` env) +- [x] 1.1 `db/schema.sql`: idempotent `orders.renews_at TIMESTAMPTZ`; `make db-up` +- [x] 1.2 `internal/notify`: Notifier (ntfy POST + sms via sms.Sender; `NOTIFY_CHANNEL` env: ntfy|sms|both; `NTFY_URL`/`NTFY_TOPIC` env) ## 2. Gating Integration -- [ ] 2.1 `scan.go`: `shouldAlert` returns (bool, reason); lapsed branch uses renews_at lazy expiry; handler calls `notifyUnlock` when blocked by lapsed or credits (message: plan lapsed / out of credits + tag link) +- [x] 2.1 `scan.go`: `shouldAlert` returns (bool, reason); lapsed branch uses renews_at lazy expiry; handler calls `notifyUnlock` when blocked by lapsed or credits (message: plan lapsed / out of credits + tag link) ## 3. Admin -- [ ] 3.1 OrderResource: `renews_at` date-time picker + table column; lapsed badge when past due +- [x] 3.1 OrderResource: `renews_at` date-time picker + table column; lapsed badge when past due ## 4. Verification -- [ ] 4.1 New suite: order paid + renews_at past → blocked + unlock notice logged; credits exhausted → blocked + notice; NOTIFY_CHANNEL=sms → notice via log sender; unpaid (non-paid status) → notice -- [ ] 4.2 Regressions (72) green; deploy (frontend binary; admin restart) -- [ ] 4.3 `openspec validate renewal-unlock`; commit +- [x] 4.1 New suite: order paid + renews_at past → blocked + unlock notice logged; credits exhausted → blocked + notice; NOTIFY_CHANNEL=sms → notice via log sender; unpaid (non-paid status) → notice +- [x] 4.2 Regressions (72) green; deploy (frontend binary; admin restart) +- [x] 4.3 `openspec validate renewal-unlock`; commit