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:
@@ -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}
|
||||
|
||||
59
frontend/internal/handlers/location.go
Normal file
59
frontend/internal/handlers/location.go
Normal 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{}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user