renewal-unlock: orders.renews_at lazy expiry + owner unlock notices (ntfy/sms channels, exempt from metering) — verified
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user