renewal-unlock: orders.renews_at lazy expiry + owner unlock notices (ntfy/sms channels, exempt from metering) — verified
This commit is contained in:
@@ -90,3 +90,6 @@ CREATE TABLE IF NOT EXISTS url_shortened (
|
|||||||
-- Phase 5.5 quick wins: customer pause + order amounts (idempotent).
|
-- 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 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;
|
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;
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ type Order struct {
|
|||||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||||
Amount pgtype.Numeric `json:"amount"`
|
Amount pgtype.Numeric `json:"amount"`
|
||||||
|
RenewsAt pgtype.Timestamptz `json:"renews_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Product struct {
|
type Product struct {
|
||||||
|
|||||||
@@ -222,7 +222,7 @@ func (q *Queries) GetLatestScanByTag(ctx context.Context, tagID int64) (Scan, er
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getOrderByID = `-- name: GetOrderByID :one
|
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) {
|
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.CreatedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
&i.Amount,
|
&i.Amount,
|
||||||
|
&i.RenewsAt,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
|
|
||||||
"wherewoof/frontend/internal/auth"
|
"wherewoof/frontend/internal/auth"
|
||||||
"wherewoof/frontend/internal/db"
|
"wherewoof/frontend/internal/db"
|
||||||
|
"wherewoof/frontend/internal/notify"
|
||||||
"wherewoof/frontend/internal/sms"
|
"wherewoof/frontend/internal/sms"
|
||||||
"wherewoof/frontend/internal/storage"
|
"wherewoof/frontend/internal/storage"
|
||||||
)
|
)
|
||||||
@@ -20,15 +21,16 @@ type Templates map[string]*template.Template
|
|||||||
|
|
||||||
// App holds dependencies shared by all handlers.
|
// App holds dependencies shared by all handlers.
|
||||||
type App struct {
|
type App struct {
|
||||||
Queries *db.Queries
|
Queries *db.Queries
|
||||||
Tpl Templates
|
Tpl Templates
|
||||||
Sender sms.Sender
|
Sender sms.Sender
|
||||||
Storage *storage.Client
|
Storage *storage.Client
|
||||||
|
Notifier *notify.Notifier
|
||||||
}
|
}
|
||||||
|
|
||||||
// New returns an App with the given query layer, template sets, SMS sender, and object storage.
|
// New returns an App with the given dependencies.
|
||||||
func New(queries *db.Queries, tpl Templates, sender sms.Sender, store *storage.Client) *App {
|
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}
|
return &App{Queries: queries, Tpl: tpl, Sender: sender, Storage: store, Notifier: ntf}
|
||||||
}
|
}
|
||||||
|
|
||||||
// PageData is the root data passed to the base layout.
|
// PageData is the root data passed to the base layout.
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
"github.com/jackc/pgx/v5/pgtype"
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
|
||||||
"wherewoof/frontend/internal/db"
|
"wherewoof/frontend/internal/db"
|
||||||
|
"wherewoof/frontend/internal/notify"
|
||||||
"wherewoof/frontend/internal/sms"
|
"wherewoof/frontend/internal/sms"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -73,7 +74,7 @@ func (a *App) Scan(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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 alertSent {
|
||||||
if a.alertOwner(r.Context(), tag, scan, req.Lat, req.Lng) {
|
if a.alertOwner(r.Context(), tag, scan, req.Lat, req.Lng) {
|
||||||
_ = a.Queries.SetScanAlertSent(r.Context(), db.SetScanAlertSentParams{ID: scan.ID, AlertSent: true})
|
_ = 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)
|
_ = 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})
|
writeJSON(w, map[string]any{"ok": true, "alert_sent": alertSent})
|
||||||
}
|
}
|
||||||
|
|
||||||
// shouldAlert applies the throttle rules and sms_enabled flag.
|
// shouldAlert applies the throttle rules and sms_enabled flag.
|
||||||
// Order: sms_enabled → owner phone → 24 h fingerprint block → paid-order gating
|
// Returns (send, reason) where reason is "lapsed" or "credits" when those
|
||||||
// → per-tag daily cap → per-IP hourly rate → 10-min window rules.
|
// 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 {
|
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 {
|
if !tag.SmsEnabled {
|
||||||
return false
|
return false, ""
|
||||||
}
|
}
|
||||||
if !tag.Phone.Valid {
|
if !tag.Phone.Valid {
|
||||||
return false
|
return false, ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// Customer pause (admin kill-switch): paused owner -> record only.
|
// Customer pause (admin kill-switch): paused owner -> record only.
|
||||||
if tag.OwnerID.Valid {
|
if tag.OwnerID.Valid {
|
||||||
if owner, err := a.Queries.GetUserByID(ctx, tag.OwnerID.Int64); err == nil && owner.Paused {
|
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.
|
// 24 h per-device block: same fingerprint seen recently (excluding this scan) => record only.
|
||||||
if fp := scan.Fingerprint.String; fp != "" {
|
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 {
|
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 tag.OrderID.Valid {
|
||||||
if order, err := a.Queries.GetOrderByID(ctx, tag.OrderID.Int64); err == nil && order.Status != "paid" {
|
if order, err := a.Queries.GetOrderByID(ctx, tag.OrderID.Int64); err == nil {
|
||||||
return false
|
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.
|
// SMS metering: a positive allocation is required; exhausted credits block alerts.
|
||||||
if tag.SmsAllocated > 0 && tag.SmsUsed >= tag.SmsAllocated {
|
if tag.SmsAllocated > 0 && tag.SmsUsed >= tag.SmsAllocated {
|
||||||
return false
|
return false, "credits"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Per-tag daily cap.
|
// 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,
|
TagID: tag.ID,
|
||||||
ScannedAt: pgtype.Timestamptz{Time: time.Now().Add(-24 * time.Hour), Valid: true},
|
ScannedAt: pgtype.Timestamptz{Time: time.Now().Add(-24 * time.Hour), Valid: true},
|
||||||
}); err == nil && n >= maxAlertsPerTagDay {
|
}); err == nil && n >= maxAlertsPerTagDay {
|
||||||
return false
|
return false, ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// Per-IP hourly rate.
|
// 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},
|
Ip: pgtype.Text{String: ip, Valid: true},
|
||||||
ScannedAt: pgtype.Timestamptz{Time: time.Now().Add(-time.Hour), Valid: true},
|
ScannedAt: pgtype.Timestamptz{Time: time.Now().Add(-time.Hour), Valid: true},
|
||||||
}); err == nil && n >= maxAlertsPerIPHour {
|
}); err == nil && n >= maxAlertsPerIPHour {
|
||||||
return false
|
return false, ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
last, err := a.Queries.GetLastAlertByTag(ctx, tag.ID)
|
last, err := a.Queries.GetLastAlertByTag(ctx, tag.ID)
|
||||||
if err != nil {
|
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?
|
// 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 &&
|
moved := hasLoc && last.Lat.Valid && last.Lng.Valid &&
|
||||||
sms.HaversineMeters(last.Lat.Float64, last.Lng.Float64, *lat, *lng) > alertMinDistance
|
sms.HaversineMeters(last.Lat.Float64, last.Lng.Float64, *lat, *lng) > alertMinDistance
|
||||||
differentPhone := phone != "" && phone != last.ScannerPhone.String
|
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.
|
// clientIP returns the client's IP from X-Forwarded-For (set by Caddy) or RemoteAddr.
|
||||||
|
|||||||
91
frontend/internal/notify/notify.go
Normal file
91
frontend/internal/notify/notify.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"wherewoof/frontend/internal/auth"
|
"wherewoof/frontend/internal/auth"
|
||||||
"wherewoof/frontend/internal/db"
|
"wherewoof/frontend/internal/db"
|
||||||
"wherewoof/frontend/internal/handlers"
|
"wherewoof/frontend/internal/handlers"
|
||||||
|
"wherewoof/frontend/internal/notify"
|
||||||
"wherewoof/frontend/internal/sms"
|
"wherewoof/frontend/internal/sms"
|
||||||
"wherewoof/frontend/internal/storage"
|
"wherewoof/frontend/internal/storage"
|
||||||
)
|
)
|
||||||
@@ -60,7 +61,7 @@ func main() {
|
|||||||
log.Fatal("storage:", err)
|
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 := http.NewServeMux()
|
||||||
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.Dir("static"))))
|
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.Dir("static"))))
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
## 1. Schema & Notify
|
## 1. Schema & Notify
|
||||||
|
|
||||||
- [ ] 1.1 `db/schema.sql`: idempotent `orders.renews_at TIMESTAMPTZ`; `make db-up`
|
- [x] 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.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. 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. 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. 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
|
- [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
|
||||||
- [ ] 4.2 Regressions (72) green; deploy (frontend binary; admin restart)
|
- [x] 4.2 Regressions (72) green; deploy (frontend binary; admin restart)
|
||||||
- [ ] 4.3 `openspec validate renewal-unlock`; commit
|
- [x] 4.3 `openspec validate renewal-unlock`; commit
|
||||||
|
|||||||
Reference in New Issue
Block a user