sms-metering-location-link: per-tag SMS credits (allocated/used, admin top-up), short URLs + owner location page (map embed + finder details) — 13/13 scenarios

This commit is contained in:
2026-08-08 14:38:32 +10:00
parent db1c358fe7
commit 5487cee966
12 changed files with 297 additions and 37 deletions

View File

@@ -57,6 +57,7 @@ func LoadTemplates() (Templates, error) {
"account": {dir + "/account.html", dir + "/account-panel.html", dir + "/tag-list.html", dir + "/tag-edit-inline.html"},
"edit": {dir + "/tag-edit.html"},
"public": {dir + "/tag-public.html"},
"location": {dir + "/location.html"},
"notfound": {dir + "/not-found.html"},
}
funcs := template.FuncMap{"title": titleCase}

View File

@@ -0,0 +1,59 @@
package handlers
import (
"errors"
"net/http"
"github.com/jackc/pgx/v5"
"wherewoof/frontend/internal/db"
)
// locationData is passed to the owner's location page template.
type locationData struct {
Lat float64
Lng float64
FinderPhone string
TagCode string
ScannedAt string
}
// ServeShort resolves a short code to its scan and renders the owner's
// location page (map embed + finder details).
func (a *App) ServeShort(w http.ResponseWriter, r *http.Request) {
code := r.PathValue("code")
sc, err := a.Queries.GetShortCode(r.Context(), code)
if err != nil {
http.NotFound(w, r)
return
}
scan, err := a.Queries.GetScanByID(r.Context(), sc.ScanID)
if err != nil || !scan.Lat.Valid || !scan.Lng.Valid {
http.NotFound(w, r)
return
}
tag, err := a.Queries.GetTagByID(r.Context(), scan.TagID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
http.NotFound(w, r)
return
}
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
data := locationData{
Lat: scan.Lat.Float64,
Lng: scan.Lng.Float64,
FinderPhone: scan.ScannerPhone.String,
TagCode: tag.TagCode,
ScannedAt: scan.ScannedAt.Time.Local().Format("Mon 2 Jan, 3:04 pm"),
}
a.render(w, r, "location", "Location found", data, "")
}
// keep the db import referenced (Tag lookup above uses a.Queries only; this
// guards against accidental removal while the package evolves).
var _ = db.Tag{}

View File

@@ -5,6 +5,7 @@ import (
"encoding/json"
"errors"
"fmt"
"math/rand"
"net"
"net/http"
"strings"
@@ -76,6 +77,10 @@ func (a *App) Scan(w http.ResponseWriter, r *http.Request) {
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})
// Metering: a successful alert consumes one credit on metered tags.
if tag.SmsAllocated > 0 {
_ = a.Queries.AddSmsUsed(r.Context(), tag.ID)
}
}
}
@@ -107,6 +112,11 @@ func (a *App) shouldAlert(ctx context.Context, tag db.Tag, scan db.Scan, hasLoc
}
}
// SMS metering: a positive allocation is required; exhausted credits block alerts.
if tag.SmsAllocated > 0 && tag.SmsUsed >= tag.SmsAllocated {
return false
}
// Per-tag daily cap.
if n, err := a.Queries.CountAlertsByTagSince(ctx, db.CountAlertsByTagSinceParams{
TagID: tag.ID,
@@ -161,7 +171,15 @@ func (a *App) alertOwner(ctx context.Context, tag db.Tag, scan db.Scan, lat, lng
if a.Sender == nil {
return false
}
msg := alertMessage(tag, scan.ScannedAt.Time, lat, lng)
// When a location is present, mint a short link so the SMS stays short and
// the owner gets a map page instead of raw coordinates.
link := ""
if lat != nil && lng != nil {
if code, err := a.mintShortCode(ctx, scan.ID); err == nil {
link = "https://where-woof.com/s/" + code
}
}
msg := alertMessage(tag, scan.ScannedAt.Time, lat, lng, link)
if err := a.Sender.Send(sms.NormalizeAU(tag.Phone.String), msg); err != nil {
fmt.Println("alert sms failed:", err)
return false
@@ -169,8 +187,24 @@ func (a *App) alertOwner(ctx context.Context, tag db.Tag, scan db.Scan, lat, lng
return true
}
// alertMessage builds the owner alert: item type + name, time, maps link, tag link.
func alertMessage(tag db.Tag, t time.Time, lat, lng *float64) string {
// mintShortCode creates a short URL code for a scan (with retry on collision).
func (a *App) mintShortCode(ctx context.Context, scanID int64) (string, error) {
const alphabet = "abcdefghijklmnopqrstuvwxyz23456789" // no 0/1/o/l
for i := 0; i < 3; i++ {
code := make([]byte, 6)
for j := range code {
code[j] = alphabet[rand.Intn(len(alphabet))]
}
c := string(code)
if _, err := a.Queries.InsertShortCode(ctx, db.InsertShortCodeParams{Code: c, ScanID: scanID}); err == nil {
return c, nil
}
}
return "", fmt.Errorf("short code collision")
}
// alertMessage builds the owner alert: item type + name, time, location link, tag link.
func alertMessage(tag db.Tag, t time.Time, lat, lng *float64, link string) string {
itemType := "Item"
if tag.ItemType.Valid {
itemType = titleCase(tag.ItemType.String)
@@ -185,7 +219,9 @@ func alertMessage(tag db.Tag, t time.Time, lat, lng *float64) string {
}
msg := fmt.Sprintf("Where Woof: your %s was scanned at %s.", label, t.Local().Format("3:04 pm"))
if lat != nil && lng != nil {
if link != "" {
msg += " Location: " + link
} else if lat != nil && lng != nil {
msg += fmt.Sprintf(" Location: https://maps.google.com/?q=%.5f,%.5f", *lat, *lng)
} else {
msg += " The finder didn't share a location."
@@ -243,6 +279,10 @@ func (a *App) FinderContact(w http.ResponseWriter, r *http.Request) {
// Should we notify? Skip when the same phone was alerted within the window,
// or when this device (fingerprint) has already alerted in the last 24 h.
notify := tag.SmsEnabled && tag.Phone.Valid && a.Sender != nil
// Metering: metered tags must have credits remaining.
if notify && tag.SmsAllocated > 0 && tag.SmsUsed >= tag.SmsAllocated {
notify = false
}
if notify && fingerprint != "" {
if _, err := a.Queries.GetRecentScanByFingerprint(r.Context(), db.GetRecentScanByFingerprintParams{Fingerprint: pgtype.Text{String: fingerprint, Valid: true}, ID: latest.ID}); err == nil {
notify = false
@@ -267,6 +307,10 @@ func (a *App) FinderContact(w http.ResponseWriter, r *http.Request) {
} else {
// Mark alerted so dedup (same phone / same device) can see it.
_ = a.Queries.SetScanAlertSent(r.Context(), db.SetScanAlertSentParams{ID: latest.ID, AlertSent: true})
// Metering: successful contact SMS consumes a credit on metered tags.
if tag.SmsAllocated > 0 {
_ = a.Queries.AddSmsUsed(r.Context(), tag.ID)
}
}
}