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

@@ -74,3 +74,15 @@ ALTER TABLE users ADD COLUMN IF NOT EXISTS remember_token TEXT;
-- Phase 5-lite: client IP on scans for rate limiting (idempotent). -- Phase 5-lite: client IP on scans for rate limiting (idempotent).
ALTER TABLE scans ADD COLUMN IF NOT EXISTS ip TEXT; ALTER TABLE scans ADD COLUMN IF NOT EXISTS ip TEXT;
-- Phase 5.5: per-tag SMS metering (0 = unmetered/transitional).
ALTER TABLE tags ADD COLUMN IF NOT EXISTS sms_allocated INTEGER NOT NULL DEFAULT 0;
ALTER TABLE tags ADD COLUMN IF NOT EXISTS sms_used INTEGER NOT NULL DEFAULT 0;
ALTER TABLE tags ADD COLUMN IF NOT EXISTS sms_period_start DATE NOT NULL DEFAULT CURRENT_DATE;
-- Short URLs for SMS links (owner location pages).
CREATE TABLE IF NOT EXISTS url_shortened (
code TEXT PRIMARY KEY,
scan_id BIGINT NOT NULL REFERENCES scans(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

View File

@@ -54,6 +54,15 @@ type Tag struct {
SmsEnabled bool `json:"sms_enabled"` SmsEnabled bool `json:"sms_enabled"`
ProductID pgtype.Int8 `json:"product_id"` ProductID pgtype.Int8 `json:"product_id"`
OrderID pgtype.Int8 `json:"order_id"` OrderID pgtype.Int8 `json:"order_id"`
SmsAllocated int32 `json:"sms_allocated"`
SmsUsed int32 `json:"sms_used"`
SmsPeriodStart pgtype.Date `json:"sms_period_start"`
}
type UrlShortened struct {
Code string `json:"code"`
ScanID int64 `json:"scan_id"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
} }
type User struct { type User struct {

View File

@@ -11,6 +11,7 @@ import (
) )
type Querier interface { type Querier interface {
AddSmsUsed(ctx context.Context, id int64) error
BindTag(ctx context.Context, arg BindTagParams) (Tag, error) BindTag(ctx context.Context, arg BindTagParams) (Tag, error)
ClearTagOwner(ctx context.Context, id int64) (Tag, error) ClearTagOwner(ctx context.Context, id int64) (Tag, error)
CountAlertsByIPSince(ctx context.Context, arg CountAlertsByIPSinceParams) (int64, error) CountAlertsByIPSince(ctx context.Context, arg CountAlertsByIPSinceParams) (int64, error)
@@ -21,11 +22,14 @@ type Querier interface {
GetLatestScanByTag(ctx context.Context, tagID int64) (Scan, error) GetLatestScanByTag(ctx context.Context, tagID int64) (Scan, error)
GetOrderByID(ctx context.Context, id int64) (Order, error) GetOrderByID(ctx context.Context, id int64) (Order, error)
GetRecentScanByFingerprint(ctx context.Context, arg GetRecentScanByFingerprintParams) (Scan, error) GetRecentScanByFingerprint(ctx context.Context, arg GetRecentScanByFingerprintParams) (Scan, error)
GetScanByID(ctx context.Context, id int64) (Scan, error)
GetShortCode(ctx context.Context, code string) (UrlShortened, error)
GetTagByCode(ctx context.Context, tagCode string) (Tag, error) GetTagByCode(ctx context.Context, tagCode string) (Tag, error)
GetTagByID(ctx context.Context, id int64) (Tag, error) GetTagByID(ctx context.Context, id int64) (Tag, error)
GetUserByEmail(ctx context.Context, email string) (User, error) GetUserByEmail(ctx context.Context, email string) (User, error)
GetUserByID(ctx context.Context, id int64) (User, error) GetUserByID(ctx context.Context, id int64) (User, error)
InsertScan(ctx context.Context, arg InsertScanParams) (Scan, error) InsertScan(ctx context.Context, arg InsertScanParams) (Scan, error)
InsertShortCode(ctx context.Context, arg InsertShortCodeParams) (UrlShortened, error)
InsertTag(ctx context.Context, tagCode string) (Tag, error) InsertTag(ctx context.Context, tagCode string) (Tag, error)
ListTagsByOwner(ctx context.Context, ownerID pgtype.Int8) ([]Tag, error) ListTagsByOwner(ctx context.Context, ownerID pgtype.Int8) ([]Tag, error)
SetScanAlertSent(ctx context.Context, arg SetScanAlertSentParams) error SetScanAlertSent(ctx context.Context, arg SetScanAlertSentParams) error

View File

@@ -85,3 +85,15 @@ WHERE tag_id = $1 AND alert_sent = TRUE AND scanned_at > $2;
-- name: CountAlertsByIPSince :one -- name: CountAlertsByIPSince :one
SELECT count(*) FROM scans SELECT count(*) FROM scans
WHERE ip = $1 AND alert_sent = TRUE AND scanned_at > $2; WHERE ip = $1 AND alert_sent = TRUE AND scanned_at > $2;
-- name: AddSmsUsed :exec
UPDATE tags SET sms_used = sms_used + 1, updated_at = now() WHERE id = $1;
-- name: InsertShortCode :one
INSERT INTO url_shortened (code, scan_id) VALUES ($1, $2) RETURNING *;
-- name: GetShortCode :one
SELECT * FROM url_shortened WHERE code = $1;
-- name: GetScanByID :one
SELECT * FROM scans WHERE id = $1;

View File

@@ -11,11 +11,20 @@ import (
"github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgtype"
) )
const addSmsUsed = `-- name: AddSmsUsed :exec
UPDATE tags SET sms_used = sms_used + 1, updated_at = now() WHERE id = $1
`
func (q *Queries) AddSmsUsed(ctx context.Context, id int64) error {
_, err := q.db.Exec(ctx, addSmsUsed, id)
return err
}
const bindTag = `-- name: BindTag :one const bindTag = `-- name: BindTag :one
UPDATE tags UPDATE tags
SET owner_id = $1, updated_at = now() SET owner_id = $1, updated_at = now()
WHERE tag_code = $2 AND owner_id IS NULL AND status = 'unset' WHERE tag_code = $2 AND owner_id IS NULL AND status = 'unset'
RETURNING id, tag_code, owner_id, status, item_type, description, photo_url, phone, address, notes, created_at, updated_at, sms_enabled, product_id, order_id RETURNING id, tag_code, owner_id, status, item_type, description, photo_url, phone, address, notes, created_at, updated_at, sms_enabled, product_id, order_id, sms_allocated, sms_used, sms_period_start
` `
type BindTagParams struct { type BindTagParams struct {
@@ -42,6 +51,9 @@ func (q *Queries) BindTag(ctx context.Context, arg BindTagParams) (Tag, error) {
&i.SmsEnabled, &i.SmsEnabled,
&i.ProductID, &i.ProductID,
&i.OrderID, &i.OrderID,
&i.SmsAllocated,
&i.SmsUsed,
&i.SmsPeriodStart,
) )
return i, err return i, err
} }
@@ -50,7 +62,7 @@ const clearTagOwner = `-- name: ClearTagOwner :one
UPDATE tags UPDATE tags
SET owner_id=NULL, status='unset', item_type=NULL, description=NULL, photo_url=NULL, phone=NULL, address=NULL, notes=NULL, updated_at=now() SET owner_id=NULL, status='unset', item_type=NULL, description=NULL, photo_url=NULL, phone=NULL, address=NULL, notes=NULL, updated_at=now()
WHERE id=$1 WHERE id=$1
RETURNING id, tag_code, owner_id, status, item_type, description, photo_url, phone, address, notes, created_at, updated_at, sms_enabled, product_id, order_id RETURNING id, tag_code, owner_id, status, item_type, description, photo_url, phone, address, notes, created_at, updated_at, sms_enabled, product_id, order_id, sms_allocated, sms_used, sms_period_start
` `
func (q *Queries) ClearTagOwner(ctx context.Context, id int64) (Tag, error) { func (q *Queries) ClearTagOwner(ctx context.Context, id int64) (Tag, error) {
@@ -72,6 +84,9 @@ func (q *Queries) ClearTagOwner(ctx context.Context, id int64) (Tag, error) {
&i.SmsEnabled, &i.SmsEnabled,
&i.ProductID, &i.ProductID,
&i.OrderID, &i.OrderID,
&i.SmsAllocated,
&i.SmsUsed,
&i.SmsPeriodStart,
) )
return i, err return i, err
} }
@@ -252,8 +267,41 @@ func (q *Queries) GetRecentScanByFingerprint(ctx context.Context, arg GetRecentS
return i, err return i, err
} }
const getScanByID = `-- name: GetScanByID :one
SELECT id, tag_id, scanned_at, lat, lng, location_shared, scanner_phone, alert_sent, fingerprint, ip FROM scans WHERE id = $1
`
func (q *Queries) GetScanByID(ctx context.Context, id int64) (Scan, error) {
row := q.db.QueryRow(ctx, getScanByID, id)
var i Scan
err := row.Scan(
&i.ID,
&i.TagID,
&i.ScannedAt,
&i.Lat,
&i.Lng,
&i.LocationShared,
&i.ScannerPhone,
&i.AlertSent,
&i.Fingerprint,
&i.Ip,
)
return i, err
}
const getShortCode = `-- name: GetShortCode :one
SELECT code, scan_id, created_at FROM url_shortened WHERE code = $1
`
func (q *Queries) GetShortCode(ctx context.Context, code string) (UrlShortened, error) {
row := q.db.QueryRow(ctx, getShortCode, code)
var i UrlShortened
err := row.Scan(&i.Code, &i.ScanID, &i.CreatedAt)
return i, err
}
const getTagByCode = `-- name: GetTagByCode :one const getTagByCode = `-- name: GetTagByCode :one
SELECT id, tag_code, owner_id, status, item_type, description, photo_url, phone, address, notes, created_at, updated_at, sms_enabled, product_id, order_id FROM tags WHERE tag_code = $1 SELECT id, tag_code, owner_id, status, item_type, description, photo_url, phone, address, notes, created_at, updated_at, sms_enabled, product_id, order_id, sms_allocated, sms_used, sms_period_start FROM tags WHERE tag_code = $1
` `
func (q *Queries) GetTagByCode(ctx context.Context, tagCode string) (Tag, error) { func (q *Queries) GetTagByCode(ctx context.Context, tagCode string) (Tag, error) {
@@ -275,12 +323,15 @@ func (q *Queries) GetTagByCode(ctx context.Context, tagCode string) (Tag, error)
&i.SmsEnabled, &i.SmsEnabled,
&i.ProductID, &i.ProductID,
&i.OrderID, &i.OrderID,
&i.SmsAllocated,
&i.SmsUsed,
&i.SmsPeriodStart,
) )
return i, err return i, err
} }
const getTagByID = `-- name: GetTagByID :one const getTagByID = `-- name: GetTagByID :one
SELECT id, tag_code, owner_id, status, item_type, description, photo_url, phone, address, notes, created_at, updated_at, sms_enabled, product_id, order_id FROM tags WHERE id = $1 SELECT id, tag_code, owner_id, status, item_type, description, photo_url, phone, address, notes, created_at, updated_at, sms_enabled, product_id, order_id, sms_allocated, sms_used, sms_period_start FROM tags WHERE id = $1
` `
func (q *Queries) GetTagByID(ctx context.Context, id int64) (Tag, error) { func (q *Queries) GetTagByID(ctx context.Context, id int64) (Tag, error) {
@@ -302,6 +353,9 @@ func (q *Queries) GetTagByID(ctx context.Context, id int64) (Tag, error) {
&i.SmsEnabled, &i.SmsEnabled,
&i.ProductID, &i.ProductID,
&i.OrderID, &i.OrderID,
&i.SmsAllocated,
&i.SmsUsed,
&i.SmsPeriodStart,
) )
return i, err return i, err
} }
@@ -388,9 +442,25 @@ func (q *Queries) InsertScan(ctx context.Context, arg InsertScanParams) (Scan, e
return i, err return i, err
} }
const insertShortCode = `-- name: InsertShortCode :one
INSERT INTO url_shortened (code, scan_id) VALUES ($1, $2) RETURNING code, scan_id, created_at
`
type InsertShortCodeParams struct {
Code string `json:"code"`
ScanID int64 `json:"scan_id"`
}
func (q *Queries) InsertShortCode(ctx context.Context, arg InsertShortCodeParams) (UrlShortened, error) {
row := q.db.QueryRow(ctx, insertShortCode, arg.Code, arg.ScanID)
var i UrlShortened
err := row.Scan(&i.Code, &i.ScanID, &i.CreatedAt)
return i, err
}
const insertTag = `-- name: InsertTag :one const insertTag = `-- name: InsertTag :one
INSERT INTO tags (tag_code) VALUES ($1) INSERT INTO tags (tag_code) VALUES ($1)
RETURNING id, tag_code, owner_id, status, item_type, description, photo_url, phone, address, notes, created_at, updated_at, sms_enabled, product_id, order_id RETURNING id, tag_code, owner_id, status, item_type, description, photo_url, phone, address, notes, created_at, updated_at, sms_enabled, product_id, order_id, sms_allocated, sms_used, sms_period_start
` `
func (q *Queries) InsertTag(ctx context.Context, tagCode string) (Tag, error) { func (q *Queries) InsertTag(ctx context.Context, tagCode string) (Tag, error) {
@@ -412,12 +482,15 @@ func (q *Queries) InsertTag(ctx context.Context, tagCode string) (Tag, error) {
&i.SmsEnabled, &i.SmsEnabled,
&i.ProductID, &i.ProductID,
&i.OrderID, &i.OrderID,
&i.SmsAllocated,
&i.SmsUsed,
&i.SmsPeriodStart,
) )
return i, err return i, err
} }
const listTagsByOwner = `-- name: ListTagsByOwner :many const listTagsByOwner = `-- name: ListTagsByOwner :many
SELECT id, tag_code, owner_id, status, item_type, description, photo_url, phone, address, notes, created_at, updated_at, sms_enabled, product_id, order_id FROM tags WHERE owner_id = $1 ORDER BY created_at DESC SELECT id, tag_code, owner_id, status, item_type, description, photo_url, phone, address, notes, created_at, updated_at, sms_enabled, product_id, order_id, sms_allocated, sms_used, sms_period_start FROM tags WHERE owner_id = $1 ORDER BY created_at DESC
` `
func (q *Queries) ListTagsByOwner(ctx context.Context, ownerID pgtype.Int8) ([]Tag, error) { func (q *Queries) ListTagsByOwner(ctx context.Context, ownerID pgtype.Int8) ([]Tag, error) {
@@ -445,6 +518,9 @@ func (q *Queries) ListTagsByOwner(ctx context.Context, ownerID pgtype.Int8) ([]T
&i.SmsEnabled, &i.SmsEnabled,
&i.ProductID, &i.ProductID,
&i.OrderID, &i.OrderID,
&i.SmsAllocated,
&i.SmsUsed,
&i.SmsPeriodStart,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@@ -503,7 +579,7 @@ const updateTagDetails = `-- name: UpdateTagDetails :one
UPDATE tags UPDATE tags
SET item_type=$2, description=$3, photo_url=$4, phone=$5, address=$6, notes=$7, sms_enabled=$8, status='active', updated_at=now() SET item_type=$2, description=$3, photo_url=$4, phone=$5, address=$6, notes=$7, sms_enabled=$8, status='active', updated_at=now()
WHERE id=$1 WHERE id=$1
RETURNING id, tag_code, owner_id, status, item_type, description, photo_url, phone, address, notes, created_at, updated_at, sms_enabled, product_id, order_id RETURNING id, tag_code, owner_id, status, item_type, description, photo_url, phone, address, notes, created_at, updated_at, sms_enabled, product_id, order_id, sms_allocated, sms_used, sms_period_start
` `
type UpdateTagDetailsParams struct { type UpdateTagDetailsParams struct {
@@ -545,6 +621,9 @@ func (q *Queries) UpdateTagDetails(ctx context.Context, arg UpdateTagDetailsPara
&i.SmsEnabled, &i.SmsEnabled,
&i.ProductID, &i.ProductID,
&i.OrderID, &i.OrderID,
&i.SmsAllocated,
&i.SmsUsed,
&i.SmsPeriodStart,
) )
return i, err return i, err
} }

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"}, "account": {dir + "/account.html", dir + "/account-panel.html", dir + "/tag-list.html", dir + "/tag-edit-inline.html"},
"edit": {dir + "/tag-edit.html"}, "edit": {dir + "/tag-edit.html"},
"public": {dir + "/tag-public.html"}, "public": {dir + "/tag-public.html"},
"location": {dir + "/location.html"},
"notfound": {dir + "/not-found.html"}, "notfound": {dir + "/not-found.html"},
} }
funcs := template.FuncMap{"title": titleCase} 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" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"math/rand"
"net" "net"
"net/http" "net/http"
"strings" "strings"
@@ -76,6 +77,10 @@ func (a *App) Scan(w http.ResponseWriter, r *http.Request) {
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})
// 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. // Per-tag daily cap.
if n, err := a.Queries.CountAlertsByTagSince(ctx, db.CountAlertsByTagSinceParams{ if n, err := a.Queries.CountAlertsByTagSince(ctx, db.CountAlertsByTagSinceParams{
TagID: tag.ID, 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 { if a.Sender == nil {
return false 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 { if err := a.Sender.Send(sms.NormalizeAU(tag.Phone.String), msg); err != nil {
fmt.Println("alert sms failed:", err) fmt.Println("alert sms failed:", err)
return false return false
@@ -169,8 +187,24 @@ func (a *App) alertOwner(ctx context.Context, tag db.Tag, scan db.Scan, lat, lng
return true return true
} }
// alertMessage builds the owner alert: item type + name, time, maps link, tag link. // mintShortCode creates a short URL code for a scan (with retry on collision).
func alertMessage(tag db.Tag, t time.Time, lat, lng *float64) string { 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" itemType := "Item"
if tag.ItemType.Valid { if tag.ItemType.Valid {
itemType = titleCase(tag.ItemType.String) 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")) 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) msg += fmt.Sprintf(" Location: https://maps.google.com/?q=%.5f,%.5f", *lat, *lng)
} else { } else {
msg += " The finder didn't share a location." 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, // 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. // or when this device (fingerprint) has already alerted in the last 24 h.
notify := tag.SmsEnabled && tag.Phone.Valid && a.Sender != nil 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 notify && fingerprint != "" {
if _, err := a.Queries.GetRecentScanByFingerprint(r.Context(), db.GetRecentScanByFingerprintParams{Fingerprint: pgtype.Text{String: fingerprint, Valid: true}, ID: latest.ID}); err == nil { if _, err := a.Queries.GetRecentScanByFingerprint(r.Context(), db.GetRecentScanByFingerprintParams{Fingerprint: pgtype.Text{String: fingerprint, Valid: true}, ID: latest.ID}); err == nil {
notify = false notify = false
@@ -267,6 +307,10 @@ func (a *App) FinderContact(w http.ResponseWriter, r *http.Request) {
} else { } else {
// Mark alerted so dedup (same phone / same device) can see it. // Mark alerted so dedup (same phone / same device) can see it.
_ = a.Queries.SetScanAlertSent(r.Context(), db.SetScanAlertSentParams{ID: latest.ID, AlertSent: true}) _ = 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)
}
} }
} }

View File

@@ -67,6 +67,7 @@ func main() {
mux.HandleFunc("GET /{$}", app.Home) mux.HandleFunc("GET /{$}", app.Home)
mux.HandleFunc("/{path...}", http.NotFound) mux.HandleFunc("/{path...}", http.NotFound)
mux.HandleFunc("GET /t/{tag_code}", app.PublicTag) mux.HandleFunc("GET /t/{tag_code}", app.PublicTag)
mux.HandleFunc("GET /s/{code}", app.ServeShort)
mux.HandleFunc("POST /t/{tag_code}/scan", app.Scan) mux.HandleFunc("POST /t/{tag_code}/scan", app.Scan)
mux.HandleFunc("POST /t/{tag_code}/contact", app.FinderContact) mux.HandleFunc("POST /t/{tag_code}/contact", app.FinderContact)
mux.HandleFunc("GET /register", app.RegisterPage) mux.HandleFunc("GET /register", app.RegisterPage)

View File

@@ -0,0 +1,31 @@
{{define "content"}}
<div class="mx-auto max-w-2xl">
<div class="overflow-hidden rounded-xl border border-stone-200 bg-white shadow-sm">
<div class="bg-stone-900 px-6 py-5 text-white">
<h1 class="text-2xl font-bold">📍 Location found</h1>
<p class="mt-1 text-sm text-stone-300">Your Where Woof tag was scanned at {{.Data.ScannedAt}}. This is where it was reported:</p>
</div>
<div class="p-6">
<div class="overflow-hidden rounded-lg border border-stone-200">
<iframe
title="Reported location"
src="https://www.google.com/maps?q={{.Data.Lat}},{{.Data.Lng}}&amp;output=embed"
class="h-72 w-full"
loading="lazy"
referrerpolicy="no-referrer-when-downgrade"></iframe>
</div>
{{if .Data.FinderPhone}}
<div class="mt-4 flex items-center justify-between rounded bg-green-50 px-4 py-3 border border-green-200">
<dt class="font-medium text-sm">The finder left their number</dt>
<dd><a href="tel:{{.Data.FinderPhone}}" class="font-semibold text-green-700 hover:underline">{{.Data.FinderPhone}}</a></dd>
</div>
{{end}}
<p class="mt-4 text-center text-sm">
<a href="/t/{{.Data.TagCode}}" class="text-amber-600 hover:underline">View this tag's return details</a>
</p>
</div>
</div>
</div>
{{end}}

View File

@@ -1,24 +1,24 @@
## 1. Schema & Queries ## 1. Schema & Queries
- [ ] 1.1 `db/schema.sql`: idempotent ALTERs `tags.sms_allocated/used/period_start`; `url_shortened` table; `make db-up` - [x] 1.1 `db/schema.sql`: idempotent ALTERs `tags.sms_allocated/used/period_start`; `url_shortened` table; `make db-up`
- [ ] 1.2 `queries.sql`: `InsertShortCode`, `GetShortCode` (:one), `GetScanByID` (:one), `AddSmsUsed` (:exec, `sms_used = sms_used + 1`); `make generate`; build - [x] 1.2 `queries.sql`: `InsertShortCode`, `GetShortCode` (:one), `GetScanByID` (:one), `AddSmsUsed` (:exec, `sms_used = sms_used + 1`); `make generate`; build
## 2. Metering ## 2. Metering
- [ ] 2.1 `scan.go` shouldAlert: credit check (`SmsAllocated > 0 && SmsUsed >= SmsAllocated` → false); after successful alert send → `AddSmsUsed` - [x] 2.1 `scan.go` shouldAlert: credit check (`SmsAllocated > 0 && SmsUsed >= SmsAllocated` → false); after successful alert send → `AddSmsUsed`
- [ ] 2.2 `FinderContact`: same credit check + increment on successful send - [x] 2.2 `FinderContact`: same credit check + increment on successful send
## 3. Location Link ## 3. Location Link
- [ ] 3.1 Short-code generator + `InsertShortCode` at alert time (when location present); `alertMessage` uses `where-woof.com/s/{code}` - [x] 3.1 Short-code generator + `InsertShortCode` at alert time (when location present); `alertMessage` uses `where-woof.com/s/{code}`
- [ ] 3.2 `ServeShort` handler (`GET /s/{code}` → scan → location page); `location.html` template (map iframe + finder phone + tag link + time); register route + template set - [x] 3.2 `ServeShort` handler (`GET /s/{code}` → scan → location page); `location.html` template (map iframe + finder phone + tag link + time); register route + template set
## 4. Admin ## 4. Admin
- [ ] 4.1 TagResource: `sms_allocated` (numeric, editable), `sms_used` (view), `sms_period_start` (view) - [x] 4.1 TagResource: `sms_allocated` (numeric, editable), `sms_used` (view), `sms_period_start` (view)
## 5. Verification ## 5. Verification
- [ ] 5.1 New suite: metered tag with credits → alert + credit consumed; exhausted → no alert (scan recorded); unmetered (0) → alerts; alert SMS contains `where-woof.com/s/`; short link → location page 200 with map + finder details; unknown code → not found - [x] 5.1 New suite: metered tag with credits → alert + credit consumed; exhausted → no alert (scan recorded); unmetered (0) → alerts; alert SMS contains `where-woof.com/s/`; short link → location page 200 with map + finder details; unknown code → not found
- [ ] 5.2 Regressions (22+19+12+6) green; deploy frontend + admin; live check - [x] 5.2 Regressions (22+19+12+6) green; deploy frontend + admin; live check
- [ ] 5.3 `openspec validate sms-metering-location-link`; commit - [x] 5.3 `openspec validate sms-metering-location-link`; commit

View File

@@ -1,4 +1,4 @@
x 2026-08-08 (A) FIX: where-woof.com DNS/redirect -> router public IP (zone editor A records, worked after propagation) +project:where-woof +agent:where_woof x 2026-08-08 2026-08-08 FIX: where-woof.com DNS/redirect -> router public IP (zone editor A records, worked after propagation) +project:where-woof +agent:where_woof
x 2026-08-08 (A) Deploy Laravel admin to .13 (Phase 8) — live on :3031 (3030=Langfuse), Caddy block updated +project:where-woof +agent:where_woof x 2026-08-08 (A) Deploy Laravel admin to .13 (Phase 8) — live on :3031 (3030=Langfuse), Caddy block updated +project:where-woof +agent:where_woof
x 2026-08-08 (A) Fix admin post-login 403 (User implements FilamentUser) + mixed-content CSS (forceScheme) +project:where-woof +agent:where_woof x 2026-08-08 (A) Fix admin post-login 403 (User implements FilamentUser) + mixed-content CSS (forceScheme) +project:where-woof +agent:where_woof
x 2026-08-07 (A) openspec: init + project.md + change frontend-foundation +project:where-woof +agent:where_woof x 2026-08-07 (A) openspec: init + project.md + change frontend-foundation +project:where-woof +agent:where_woof
@@ -18,3 +18,11 @@ x 2026-08-07 (B) Preset tag-ID registry: seed real manufactured IDs, enforce reg
(C) Photo uploads + object storage (Phase 6 — basic local upload done) +project:where-woof +agent:where_woof (C) Photo uploads + object storage (Phase 6 — basic local upload done) +project:where-woof +agent:where_woof
(C) Anti-abuse hardening (relay numbers, rate limits — Phase 9) +project:where-woof +agent:where_woof (C) Anti-abuse hardening (relay numbers, rate limits — Phase 9) +project:where-woof +agent:where_woof
(C) Live SMS creds on .13 service env (uncomment SMS_USER/PASSWORD/FROM in ~/.config/where-woof.env) +project:where-woof +agent:where_woof (C) Live SMS creds on .13 service env (uncomment SMS_USER/PASSWORD/FROM in ~/.config/where-woof.env) +project:where-woof +agent:where_woof
x 2026-08-08 2026-08-08 Billing phase +
(A) Customer insights eg total tags, customers, revenue +area:Dashboard-home
(A) Customers Tags +area:Dashboard-users
(A) Tags are associated with a customer, so no new account needed +area:concept
(A) Pause customer - unpaid +area:Dashboard-user
(A) Do we tie this all into an established Open Source inventory payment system - keep qick dash for now? +area:CONCEPT
(A) Order-tags. Tie in the inventory system? +area:Where-woof
(A) General webiste pages - what is this? Get in touch? About Us. +area:Where-woof