scan-flow: geolocation scan, location-aware SMS throttle (log sender), finder contact, sms_enabled toggle, branding — 19/19 phase-2 scenarios pass

This commit is contained in:
2026-08-05 18:23:34 +10:00
parent 4666751e92
commit c2fa04afd0
26 changed files with 698 additions and 43 deletions

View File

@@ -32,6 +32,7 @@ type Tag struct {
Notes pgtype.Text `json:"notes"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
SmsEnabled bool `json:"sms_enabled"`
}
type User struct {

View File

@@ -15,12 +15,17 @@ type Querier interface {
ClearTagOwner(ctx context.Context, id int64) (Tag, error)
CountTagsByOwner(ctx context.Context, ownerID pgtype.Int8) (int64, error)
CreateUser(ctx context.Context, arg CreateUserParams) (User, error)
GetLastAlertByTag(ctx context.Context, tagID int64) (Scan, error)
GetLatestScanByTag(ctx context.Context, tagID int64) (Scan, error)
GetTagByCode(ctx context.Context, tagCode string) (Tag, error)
GetTagByID(ctx context.Context, id int64) (Tag, error)
GetUserByEmail(ctx context.Context, email string) (User, error)
GetUserByID(ctx context.Context, id int64) (User, error)
InsertScan(ctx context.Context, arg InsertScanParams) (Scan, error)
InsertTag(ctx context.Context, tagCode string) (Tag, error)
ListTagsByOwner(ctx context.Context, ownerID pgtype.Int8) ([]Tag, error)
SetScanAlertSent(ctx context.Context, arg SetScanAlertSentParams) error
SetScanPhone(ctx context.Context, arg SetScanPhoneParams) error
SetTagStatus(ctx context.Context, arg SetTagStatusParams) error
UpdateTagDetails(ctx context.Context, arg UpdateTagDetailsParams) (Tag, error)
}

View File

@@ -29,7 +29,7 @@ RETURNING *;
-- name: UpdateTagDetails :one
UPDATE tags
SET item_type=$2, description=$3, photo_url=$4, phone=$5, address=$6, notes=$7, 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
RETURNING *;
@@ -45,3 +45,26 @@ UPDATE tags SET status=$2, updated_at=now() WHERE id=$1;
-- name: InsertTag :one
INSERT INTO tags (tag_code) VALUES ($1)
RETURNING *;
-- name: InsertScan :one
INSERT INTO scans (tag_id, lat, lng, location_shared, scanner_phone)
VALUES ($1, $2, $3, $4, $5)
RETURNING *;
-- name: GetLastAlertByTag :one
SELECT * FROM scans
WHERE tag_id = $1 AND alert_sent = TRUE
ORDER BY scanned_at DESC
LIMIT 1;
-- name: GetLatestScanByTag :one
SELECT * FROM scans
WHERE tag_id = $1
ORDER BY scanned_at DESC
LIMIT 1;
-- name: SetScanAlertSent :exec
UPDATE scans SET alert_sent = $2 WHERE id = $1;
-- name: SetScanPhone :exec
UPDATE scans SET scanner_phone = $2 WHERE id = $1;

View File

@@ -15,7 +15,7 @@ const bindTag = `-- name: BindTag :one
UPDATE tags
SET owner_id = $1, updated_at = now()
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
RETURNING id, tag_code, owner_id, status, item_type, description, photo_url, phone, address, notes, created_at, updated_at, sms_enabled
`
type BindTagParams struct {
@@ -39,6 +39,7 @@ func (q *Queries) BindTag(ctx context.Context, arg BindTagParams) (Tag, error) {
&i.Notes,
&i.CreatedAt,
&i.UpdatedAt,
&i.SmsEnabled,
)
return i, err
}
@@ -47,7 +48,7 @@ const clearTagOwner = `-- name: ClearTagOwner :one
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()
WHERE id=$1
RETURNING id, tag_code, owner_id, status, item_type, description, photo_url, phone, address, notes, created_at, updated_at
RETURNING id, tag_code, owner_id, status, item_type, description, photo_url, phone, address, notes, created_at, updated_at, sms_enabled
`
func (q *Queries) ClearTagOwner(ctx context.Context, id int64) (Tag, error) {
@@ -66,6 +67,7 @@ func (q *Queries) ClearTagOwner(ctx context.Context, id int64) (Tag, error) {
&i.Notes,
&i.CreatedAt,
&i.UpdatedAt,
&i.SmsEnabled,
)
return i, err
}
@@ -113,8 +115,54 @@ func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, e
return i, err
}
const getLastAlertByTag = `-- name: GetLastAlertByTag :one
SELECT id, tag_id, scanned_at, lat, lng, location_shared, scanner_phone, alert_sent FROM scans
WHERE tag_id = $1 AND alert_sent = TRUE
ORDER BY scanned_at DESC
LIMIT 1
`
func (q *Queries) GetLastAlertByTag(ctx context.Context, tagID int64) (Scan, error) {
row := q.db.QueryRow(ctx, getLastAlertByTag, tagID)
var i Scan
err := row.Scan(
&i.ID,
&i.TagID,
&i.ScannedAt,
&i.Lat,
&i.Lng,
&i.LocationShared,
&i.ScannerPhone,
&i.AlertSent,
)
return i, err
}
const getLatestScanByTag = `-- name: GetLatestScanByTag :one
SELECT id, tag_id, scanned_at, lat, lng, location_shared, scanner_phone, alert_sent FROM scans
WHERE tag_id = $1
ORDER BY scanned_at DESC
LIMIT 1
`
func (q *Queries) GetLatestScanByTag(ctx context.Context, tagID int64) (Scan, error) {
row := q.db.QueryRow(ctx, getLatestScanByTag, tagID)
var i Scan
err := row.Scan(
&i.ID,
&i.TagID,
&i.ScannedAt,
&i.Lat,
&i.Lng,
&i.LocationShared,
&i.ScannerPhone,
&i.AlertSent,
)
return i, err
}
const getTagByCode = `-- name: GetTagByCode :one
SELECT id, tag_code, owner_id, status, item_type, description, photo_url, phone, address, notes, created_at, updated_at 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 FROM tags WHERE tag_code = $1
`
func (q *Queries) GetTagByCode(ctx context.Context, tagCode string) (Tag, error) {
@@ -133,12 +181,13 @@ func (q *Queries) GetTagByCode(ctx context.Context, tagCode string) (Tag, error)
&i.Notes,
&i.CreatedAt,
&i.UpdatedAt,
&i.SmsEnabled,
)
return i, err
}
const getTagByID = `-- name: GetTagByID :one
SELECT id, tag_code, owner_id, status, item_type, description, photo_url, phone, address, notes, created_at, updated_at 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 FROM tags WHERE id = $1
`
func (q *Queries) GetTagByID(ctx context.Context, id int64) (Tag, error) {
@@ -157,6 +206,7 @@ func (q *Queries) GetTagByID(ctx context.Context, id int64) (Tag, error) {
&i.Notes,
&i.CreatedAt,
&i.UpdatedAt,
&i.SmsEnabled,
)
return i, err
}
@@ -197,9 +247,45 @@ func (q *Queries) GetUserByID(ctx context.Context, id int64) (User, error) {
return i, err
}
const insertScan = `-- name: InsertScan :one
INSERT INTO scans (tag_id, lat, lng, location_shared, scanner_phone)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, tag_id, scanned_at, lat, lng, location_shared, scanner_phone, alert_sent
`
type InsertScanParams struct {
TagID int64 `json:"tag_id"`
Lat pgtype.Float8 `json:"lat"`
Lng pgtype.Float8 `json:"lng"`
LocationShared bool `json:"location_shared"`
ScannerPhone pgtype.Text `json:"scanner_phone"`
}
func (q *Queries) InsertScan(ctx context.Context, arg InsertScanParams) (Scan, error) {
row := q.db.QueryRow(ctx, insertScan,
arg.TagID,
arg.Lat,
arg.Lng,
arg.LocationShared,
arg.ScannerPhone,
)
var i Scan
err := row.Scan(
&i.ID,
&i.TagID,
&i.ScannedAt,
&i.Lat,
&i.Lng,
&i.LocationShared,
&i.ScannerPhone,
&i.AlertSent,
)
return i, err
}
const insertTag = `-- name: InsertTag :one
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
RETURNING id, tag_code, owner_id, status, item_type, description, photo_url, phone, address, notes, created_at, updated_at, sms_enabled
`
func (q *Queries) InsertTag(ctx context.Context, tagCode string) (Tag, error) {
@@ -218,12 +304,13 @@ func (q *Queries) InsertTag(ctx context.Context, tagCode string) (Tag, error) {
&i.Notes,
&i.CreatedAt,
&i.UpdatedAt,
&i.SmsEnabled,
)
return i, err
}
const listTagsByOwner = `-- name: ListTagsByOwner :many
SELECT id, tag_code, owner_id, status, item_type, description, photo_url, phone, address, notes, created_at, updated_at 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 FROM tags WHERE owner_id = $1 ORDER BY created_at DESC
`
func (q *Queries) ListTagsByOwner(ctx context.Context, ownerID pgtype.Int8) ([]Tag, error) {
@@ -248,6 +335,7 @@ func (q *Queries) ListTagsByOwner(ctx context.Context, ownerID pgtype.Int8) ([]T
&i.Notes,
&i.CreatedAt,
&i.UpdatedAt,
&i.SmsEnabled,
); err != nil {
return nil, err
}
@@ -259,6 +347,34 @@ func (q *Queries) ListTagsByOwner(ctx context.Context, ownerID pgtype.Int8) ([]T
return items, nil
}
const setScanAlertSent = `-- name: SetScanAlertSent :exec
UPDATE scans SET alert_sent = $2 WHERE id = $1
`
type SetScanAlertSentParams struct {
ID int64 `json:"id"`
AlertSent bool `json:"alert_sent"`
}
func (q *Queries) SetScanAlertSent(ctx context.Context, arg SetScanAlertSentParams) error {
_, err := q.db.Exec(ctx, setScanAlertSent, arg.ID, arg.AlertSent)
return err
}
const setScanPhone = `-- name: SetScanPhone :exec
UPDATE scans SET scanner_phone = $2 WHERE id = $1
`
type SetScanPhoneParams struct {
ID int64 `json:"id"`
ScannerPhone pgtype.Text `json:"scanner_phone"`
}
func (q *Queries) SetScanPhone(ctx context.Context, arg SetScanPhoneParams) error {
_, err := q.db.Exec(ctx, setScanPhone, arg.ID, arg.ScannerPhone)
return err
}
const setTagStatus = `-- name: SetTagStatus :exec
UPDATE tags SET status=$2, updated_at=now() WHERE id=$1
`
@@ -275,9 +391,9 @@ func (q *Queries) SetTagStatus(ctx context.Context, arg SetTagStatusParams) erro
const updateTagDetails = `-- name: UpdateTagDetails :one
UPDATE tags
SET item_type=$2, description=$3, photo_url=$4, phone=$5, address=$6, notes=$7, 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
RETURNING id, tag_code, owner_id, status, item_type, description, photo_url, phone, address, notes, created_at, updated_at
RETURNING id, tag_code, owner_id, status, item_type, description, photo_url, phone, address, notes, created_at, updated_at, sms_enabled
`
type UpdateTagDetailsParams struct {
@@ -288,6 +404,7 @@ type UpdateTagDetailsParams struct {
Phone pgtype.Text `json:"phone"`
Address pgtype.Text `json:"address"`
Notes pgtype.Text `json:"notes"`
SmsEnabled bool `json:"sms_enabled"`
}
func (q *Queries) UpdateTagDetails(ctx context.Context, arg UpdateTagDetailsParams) (Tag, error) {
@@ -299,6 +416,7 @@ func (q *Queries) UpdateTagDetails(ctx context.Context, arg UpdateTagDetailsPara
arg.Phone,
arg.Address,
arg.Notes,
arg.SmsEnabled,
)
var i Tag
err := row.Scan(
@@ -314,6 +432,7 @@ func (q *Queries) UpdateTagDetails(ctx context.Context, arg UpdateTagDetailsPara
&i.Notes,
&i.CreatedAt,
&i.UpdatedAt,
&i.SmsEnabled,
)
return i, err
}

View File

@@ -9,6 +9,7 @@ import (
"wherewoof/frontend/internal/auth"
"wherewoof/frontend/internal/db"
"wherewoof/frontend/internal/sms"
)
// Templates maps a page key to its parsed template set (base + page + partials).
@@ -20,11 +21,12 @@ type Templates map[string]*template.Template
type App struct {
Queries *db.Queries
Tpl Templates
Sender sms.Sender
}
// New returns an App with the given query layer and template sets.
func New(queries *db.Queries, tpl Templates) *App {
return &App{Queries: queries, Tpl: tpl}
// New returns an App with the given query layer, template sets, and SMS sender.
func New(queries *db.Queries, tpl Templates, sender sms.Sender) *App {
return &App{Queries: queries, Tpl: tpl, Sender: sender}
}
// PageData is the root data passed to the base layout.

View File

@@ -0,0 +1,194 @@
package handlers
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"wherewoof/frontend/internal/db"
"wherewoof/frontend/internal/sms"
)
const (
alertWindow = 10 * time.Minute
alertMinDistance = 250.0 // metres
)
// ScanRequest is the JSON body posted by the geolocation script.
type ScanRequest struct {
Lat *float64 `json:"lat"`
Lng *float64 `json:"lng"`
}
// Scan records a scan (with optional location) and alerts the owner per the
// location-aware throttle rules: within the 10-minute window an alert is only
// re-sent when the new location is more than 250 m from the last alerted spot.
func (a *App) Scan(w http.ResponseWriter, r *http.Request) {
tag, err := a.Queries.GetTagByCode(r.Context(), r.PathValue("tag_code"))
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
http.Error(w, "tag not found", http.StatusNotFound)
return
}
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
var req ScanRequest
_ = json.NewDecoder(r.Body).Decode(&req)
hasLoc := req.Lat != nil && req.Lng != nil
var lat, lng pgtype.Float8
if hasLoc {
lat = pgtype.Float8{Float64: *req.Lat, Valid: true}
lng = pgtype.Float8{Float64: *req.Lng, Valid: true}
}
scan, err := a.Queries.InsertScan(r.Context(), db.InsertScanParams{
TagID: tag.ID,
Lat: lat,
Lng: lng,
LocationShared: hasLoc,
})
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
alertSent := a.shouldAlert(r.Context(), tag, scan, hasLoc, req.Lat, req.Lng)
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})
}
}
writeJSON(w, map[string]any{"ok": true, "alert_sent": alertSent})
}
// shouldAlert applies the throttle rules and sms_enabled flag.
func (a *App) shouldAlert(ctx context.Context, tag db.Tag, scan db.Scan, hasLoc bool, lat, lng *float64) bool {
if !tag.SmsEnabled {
return false
}
if !tag.Phone.Valid {
return false
}
last, err := a.Queries.GetLastAlertByTag(ctx, tag.ID)
if err != nil {
return errors.Is(err, pgx.ErrNoRows) // no prior alert -> alert
}
// Within the window?
if time.Since(last.ScannedAt.Time) < alertWindow {
// Re-alert only if we have a location and it moved >250 m.
if !hasLoc || !last.Lat.Valid || !last.Lng.Valid {
return false
}
return sms.HaversineMeters(last.Lat.Float64, last.Lng.Float64, *lat, *lng) > alertMinDistance
}
return true
}
// alertOwner sends the SMS to the owner and reports success.
func (a *App) alertOwner(ctx context.Context, tag db.Tag, scan db.Scan, lat, lng *float64) bool {
if a.Sender == nil {
return false
}
msg := alertMessage(tag, scan.ScannedAt.Time, lat, lng)
if err := a.Sender.Send(sms.NormalizeAU(tag.Phone.String), msg); err != nil {
fmt.Println("alert sms failed:", err)
return false
}
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 {
itemType := "Item"
if tag.ItemType.Valid {
itemType = titleCase(tag.ItemType.String)
}
name := strings.TrimSpace(tag.Description.String)
if len(name) > 30 {
name = name[:30] + "…"
}
label := itemType
if name != "" {
label = itemType + ", " + name
}
msg := fmt.Sprintf("Where Woof: your %s was scanned at %s.", label, t.Local().Format("3:04 pm"))
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."
}
msg += fmt.Sprintf(" See: https://where-woof.com/t/%s", tag.TagCode)
return msg
}
// FinderContact stores a finder's number on the latest scan and alerts the owner.
func (a *App) FinderContact(w http.ResponseWriter, r *http.Request) {
tag, err := a.Queries.GetTagByCode(r.Context(), r.PathValue("tag_code"))
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
http.Error(w, "tag not found", http.StatusNotFound)
return
}
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
phone := sms.NormalizeAU(strings.TrimSpace(r.FormValue("phone")))
if len(phone) < 8 || len(phone) > 15 {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprint(w, `<p class="mt-4 rounded border border-red-200 bg-red-50 px-4 py-2 text-sm text-red-700">Please enter a valid phone number.</p>`)
return
}
latest, err := a.Queries.GetLatestScanByTag(r.Context(), tag.ID)
if err != nil {
if !errors.Is(err, pgx.ErrNoRows) {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
// No scan yet: record one carrying the finder's number.
_, err = a.Queries.InsertScan(r.Context(), db.InsertScanParams{
TagID: tag.ID, ScannerPhone: pgtype.Text{String: phone, Valid: true},
})
} else {
err = a.Queries.SetScanPhone(r.Context(), db.SetScanPhoneParams{ID: latest.ID, ScannerPhone: pgtype.Text{String: phone, Valid: true}})
}
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if tag.SmsEnabled && tag.Phone.Valid && a.Sender != nil {
itemType := "item"
if tag.ItemType.Valid {
itemType = tag.ItemType.String
}
msg := fmt.Sprintf("Where Woof: a finder of your %s left their number: %s. See: https://where-woof.com/t/%s",
itemType, phone, tag.TagCode)
if err := a.Sender.Send(sms.NormalizeAU(tag.Phone.String), msg); err != nil {
fmt.Println("contact sms failed:", err)
}
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprint(w, `<p class="mt-4 rounded border border-green-200 bg-green-50 px-4 py-2 text-sm text-green-800">Thanks! The owner has been notified with your number.</p>`)
}
func writeJSON(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(v)
}

View File

@@ -105,6 +105,7 @@ func (a *App) EditTag(w http.ResponseWriter, r *http.Request) {
Phone: textOrNil(strings.TrimSpace(r.FormValue("phone"))),
Address: textOrNil(strings.TrimSpace(r.FormValue("address"))),
Notes: textOrNil(strings.TrimSpace(r.FormValue("notes"))),
SmsEnabled: r.FormValue("sms_enabled") == "on",
}
if _, err := a.Queries.UpdateTagDetails(r.Context(), params); err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)

View File

@@ -0,0 +1,16 @@
package sms
import "math"
const earthRadiusM = 6371000.0
// HaversineMeters returns the great-circle distance between two points
// (lat/lng in decimal degrees) in metres.
func HaversineMeters(lat1, lng1, lat2, lng2 float64) float64 {
rad := func(d float64) float64 { return d * math.Pi / 180 }
dLat := rad(lat2 - lat1)
dLng := rad(lng2 - lng1)
a := math.Sin(dLat/2)*math.Sin(dLat/2) +
math.Cos(rad(lat1))*math.Cos(rad(lat2))*math.Sin(dLng/2)*math.Sin(dLng/2)
return 2 * earthRadiusM * math.Asin(math.Sqrt(a))
}

View File

@@ -0,0 +1,21 @@
package sms
import "strings"
// NormalizeAU normalises a phone number to international AU format without a
// leading '+' or zero:
//
// "+61432374487" -> "61432374487"
// "0432374487" -> "61432374487"
func NormalizeAU(phone string) string {
s := strings.Map(func(r rune) rune {
if r >= '0' && r <= '9' {
return r
}
return -1
}, phone)
if strings.HasPrefix(s, "0") && len(s) >= 10 {
s = "61" + s[1:]
}
return s
}

View File

@@ -0,0 +1,9 @@
// Package sms provides a swappable SMS sending abstraction for WhereWoof.
// The log sender is the default so development and tests never send real SMS.
package sms
// Sender sends an SMS to a destination number in international format
// (no leading '+', no leading zero — see NormalizeAU).
type Sender interface {
Send(to, body string) error
}

View File

@@ -0,0 +1,52 @@
package sms
import (
"math"
"testing"
)
func TestHaversineMeters(t *testing.T) {
cases := []struct {
name string
lat1, lng1 float64
lat2, lng2 float64
wantMeters float64
tolerancePct float64
}{
{"same point", -33.86, 151.21, -33.86, 151.21, 0, 0},
{"Sydney CBD to Parramatta (~20km)", -33.8688, 151.2093, -33.8125, 151.0011, 20200, 0.02},
{"~250m at Sydney latitude", -33.86, 151.21, -33.86, 151.2128, 250, 0.20},
{"antipodal-ish long distance", 0, 0, 0, 180, 20037500, 0.01},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := HaversineMeters(tc.lat1, tc.lng1, tc.lat2, tc.lng2)
if tc.wantMeters == 0 {
if got > 1 {
t.Fatalf("same point: got %.1f m, want ~0", got)
}
return
}
diff := math.Abs(got-tc.wantMeters) / tc.wantMeters
if diff > tc.tolerancePct {
t.Fatalf("got %.1f m, want ~%.1f m (error %.1f%%)", got, tc.wantMeters, diff*100)
}
})
}
}
func TestNormalizeAU(t *testing.T) {
cases := []struct{ in, want string }{
{"+61432374487", "61432374487"},
{"0432374487", "61432374487"},
{"61432374487", "61432374487"},
{"+61 432 374 487", "61432374487"},
{"(04) 3237 4487", "61432374487"},
{"", ""},
}
for _, tc := range cases {
if got := NormalizeAU(tc.in); got != tc.want {
t.Errorf("NormalizeAU(%q) = %q, want %q", tc.in, got, tc.want)
}
}
}

View File

@@ -0,0 +1,85 @@
package sms
// ⚠️ VERIFY BEFORE REAL SMS (scan-flow task 9): the exact canonical-string
// format for the HMAC signature and the JSON field names must be confirmed
// against the account holder's MXT docs / their existing integration before
// the real-SMS test. The Sender interface keeps this implementation swappable
// if the auth scheme differs.
import (
"bytes"
"crypto/hmac"
"crypto/md5"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
// Client sends SMS via the SMSGlobal REST API.
type Client struct {
apiKey string
apiSecret string
from string // empty => SMSGlobal shared/pooled number
http *http.Client
}
// New returns an SMSGlobal client. from may be empty to use the shared pool.
func New(apiKey, apiSecret, from string) *Client {
return &Client{
apiKey: apiKey,
apiSecret: apiSecret,
from: from,
http: &http.Client{Timeout: 10 * time.Second},
}
}
// Send posts a message to the given destination (international format).
func (c *Client) Send(to, body string) error {
payload, err := json.Marshal(map[string]string{
"message": body,
"to": to,
"from": c.from,
})
if err != nil {
return err
}
req, err := http.NewRequest(http.MethodPost, "https://api.smsglobal.com/sms/", bytes.NewReader(payload))
if err != nil {
return err
}
date := time.Now().UTC().Format(http.TimeFormat)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
req.Header.Set("Date", date)
req.Header.Set("Authorization", c.authorization(req, payload, date))
resp, err := c.http.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
b, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return fmt.Errorf("smsglobal: status %d: %s", resp.StatusCode, string(b))
}
return nil
}
// authorization builds the REST v1 HMAC-SHA256 signature:
// base64(HMAC-SHA256(canonicalString, apiSecret)), sent as "key:signature".
// Canonical string per SMSGlobal docs: method&path&accept&date&contentType&contentMd5.
func (c *Client) authorization(req *http.Request, body []byte, date string) string {
bodyHash := md5.Sum(body)
canonical := fmt.Sprintf("%s&%s&%s&%s&%s&%s",
req.Method, req.URL.Path, "application/json", date, "application/json",
base64.StdEncoding.EncodeToString(bodyHash[:]))
mac := hmac.New(sha256.New, []byte(c.apiSecret))
mac.Write([]byte(canonical))
sig := base64.StdEncoding.EncodeToString(mac.Sum(nil))
return fmt.Sprintf("%s:%s", c.apiKey, sig)
}

View File

@@ -0,0 +1,13 @@
package sms
import "log"
// LogSender writes messages to the log instead of sending.
// Used automatically when no SMS credentials are configured.
type LogSender struct{}
// Send logs the would-be SMS.
func (LogSender) Send(to, body string) error {
log.Printf("SMS to %s: %s", to, body)
return nil
}

View File

@@ -12,6 +12,7 @@ import (
"wherewoof/frontend/internal/auth"
"wherewoof/frontend/internal/db"
"wherewoof/frontend/internal/handlers"
"wherewoof/frontend/internal/sms"
)
func main() {
@@ -42,13 +43,21 @@ func main() {
log.Fatal("templates:", err)
}
app := handlers.New(db.New(pool), tpl)
// SMS sender: log-only unless real SMSGlobal credentials are configured.
var sender sms.Sender = sms.LogSender{}
if key := os.Getenv("SMS_API_KEY"); key != "" {
sender = sms.New(key, os.Getenv("SMS_API_SECRET"), os.Getenv("SMS_FROM"))
}
app := handlers.New(db.New(pool), tpl, sender)
mux := http.NewServeMux()
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.Dir("static"))))
mux.HandleFunc("GET /{$}", app.Home)
mux.HandleFunc("/{path...}", http.NotFound)
mux.HandleFunc("GET /t/{tag_code}", app.PublicTag)
mux.HandleFunc("POST /t/{tag_code}/scan", app.Scan)
mux.HandleFunc("POST /t/{tag_code}/contact", app.FinderContact)
mux.HandleFunc("GET /register", app.RegisterPage)
mux.HandleFunc("POST /register", app.Register)
mux.HandleFunc("GET /login", app.LoginPage)

View File

@@ -4,7 +4,7 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{.Title}} · WhereWoof</title>
<title>{{.Title}} · Where Woof !</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/htmx.org@1.9.12"></script>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
@@ -12,7 +12,7 @@
<body class="min-h-full bg-stone-100 text-stone-900 antialiased">
<nav class="bg-stone-900 text-white">
<div class="mx-auto flex max-w-4xl items-center justify-between px-4 py-3">
<a href="/" class="text-lg font-bold tracking-tight">🐕 WhereWoof</a>
<a href="/" class="text-lg font-bold tracking-tight">🐕 Where Woof</a>
<div class="flex items-center gap-4 text-sm">
{{if .CurrentUser}}
<span class="text-stone-300">Hi, {{.CurrentUser.Name}}</span>

View File

@@ -2,7 +2,7 @@
<div class="mx-auto max-w-2xl text-center">
<h1 class="text-4xl font-extrabold tracking-tight">Lost something?<br><span class="text-amber-600">The tag brings it home.</span></h1>
<p class="mt-4 text-lg text-stone-600">
WhereWoof tags carry the return details for your dog, baggage, skis — anything you care about.
Where Woof tags carry the return details for your dog, baggage, skis — anything you care about.
A finder scans the QR code or taps the NFC tag and sees exactly how to get it back to you.
</p>

View File

@@ -1,7 +1,7 @@
{{define "content"}}
<div class="mx-auto max-w-md">
<h1 class="text-2xl font-bold">Log in</h1>
<p class="mt-1 text-sm text-stone-600">Welcome back to WhereWoof.</p>
<p class="mt-1 text-sm text-stone-600">Welcome back to Where Woof.</p>
{{if .Error}}<p class="mt-4 rounded bg-red-50 px-4 py-2 text-sm text-red-700 border border-red-200">{{.Error}}</p>{{end}}
@@ -17,6 +17,6 @@
<button type="submit" class="w-full rounded bg-stone-900 px-4 py-2 font-semibold text-white hover:bg-stone-700">Log in</button>
</form>
<p class="mt-4 text-center text-sm text-stone-600">New to WhereWoof? <a href="/register" class="text-amber-600 hover:underline">Create an account</a></p>
<p class="mt-4 text-center text-sm text-stone-600">New to Where Woof? <a href="/register" class="text-amber-600 hover:underline">Create an account</a></p>
</div>
{{end}}

View File

@@ -2,7 +2,7 @@
<div class="mx-auto max-w-md text-center">
<div class="text-5xl">🔎</div>
<h1 class="mt-4 text-2xl font-bold">Tag not found</h1>
<p class="mt-2 text-stone-600">We couldn't find a WhereWoof tag with that code. Check the code on the tag and try again.</p>
<p class="mt-2 text-stone-600">We couldn't find a Where Woof tag with that code. Check the code on the tag and try again.</p>
<a href="/" class="mt-6 inline-block rounded bg-stone-900 px-4 py-2 text-sm font-semibold text-white hover:bg-stone-700">Go home</a>
</div>
{{end}}

View File

@@ -1,7 +1,7 @@
{{define "content"}}
<div class="mx-auto max-w-md">
<h1 class="text-2xl font-bold">Create your account</h1>
<p class="mt-1 text-sm text-stone-600">Register to claim your WhereWoof tag.</p>
<p class="mt-1 text-sm text-stone-600">Register to claim your Where Woof tag.</p>
{{if .Error}}<p class="mt-4 rounded bg-red-50 px-4 py-2 text-sm text-red-700 border border-red-200">{{.Error}}</p>{{end}}

View File

@@ -40,6 +40,10 @@
<label class="block text-sm font-medium">Notes</label>
<textarea name="notes" rows="2" class="mt-1 w-full rounded border border-stone-300 px-3 py-2">{{if .Data.Notes.Valid}}{{.Data.Notes.String}}{{end}}</textarea>
</div>
<label class="flex items-center gap-2 text-sm">
<input type="checkbox" name="sms_enabled" value="on" {{if .Data.SmsEnabled}}checked{{end}} class="h-4 w-4">
Send me SMS alerts when this tag is scanned
</label>
<div class="flex gap-3">
<button type="submit" class="rounded bg-stone-900 px-4 py-2 font-semibold text-white hover:bg-stone-700">Save details</button>
<a href="/account" class="rounded border border-stone-300 px-4 py-2 text-sm font-medium hover:bg-stone-50">Cancel</a>

View File

@@ -4,7 +4,7 @@
<div class="rounded-xl border border-stone-200 bg-white p-8 text-center shadow-sm">
<div class="text-5xl">🏷️</div>
<h1 class="mt-4 text-2xl font-bold">This tag isn't set up yet</h1>
<p class="mt-2 text-stone-600">This WhereWoof tag hasn't been claimed. If this is your tag, log in and add it to your account to set up the return details.</p>
<p class="mt-2 text-stone-600">This Where Woof tag hasn't been claimed. If this is your tag, log in and add it to your account to set up the return details.</p>
<div class="mt-6 flex justify-center gap-3">
<a href="/login" class="rounded bg-stone-900 px-4 py-2 text-sm font-semibold text-white hover:bg-stone-700">Log in</a>
<a href="/register" class="rounded bg-amber-500 px-4 py-2 text-sm font-semibold text-stone-900 hover:bg-amber-400">Create account</a>
@@ -22,7 +22,7 @@
<h1 class="text-2xl font-bold">
{{if .Data.ItemType.Valid}}{{.Data.ItemType.String | title}}{{else}}Item{{end}} — found!
</h1>
<p class="mt-1 text-sm text-stone-300">This item has a WhereWoof tag. Here's how to return it:</p>
<p class="mt-1 text-sm text-stone-300">This item has a Where Woof tag. Here's how to return it:</p>
</div>
<div class="p-6">
{{if .Data.PhotoUrl.Valid}}
@@ -37,6 +37,10 @@
<dt class="font-medium">Call the owner</dt>
<dd><a href="tel:{{.Data.Phone.String}}" class="font-semibold text-amber-600 hover:underline">{{.Data.Phone.String}}</a></dd>
</div>
<div class="flex items-center justify-between rounded bg-stone-50 px-4 py-3">
<dt class="font-medium">Text the owner</dt>
<dd><a href="sms:{{.Data.Phone.String}}?&body=Hi! I found your {{if .Data.ItemType.Valid}}{{.Data.ItemType.String}}{{else}}item{{end}}{{if .Data.Description.Valid}}, {{.Data.Description.String}}{{end}}. My phone number is: " class="font-semibold text-amber-600 hover:underline">Send an SMS</a></dd>
</div>
{{end}}
{{if .Data.Address.Valid}}
<div class="flex items-center justify-between rounded bg-stone-50 px-4 py-3">
@@ -51,14 +55,51 @@
</div>
{{end}}
</dl>
<p class="mt-6 rounded bg-amber-50 px-4 py-3 text-sm text-amber-800 border border-amber-200">
Found this item? Please contact the owner to arrange the return. Thank you for helping!
</p>
<div class="mt-4 rounded border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
<p><strong>Help the owner find it:</strong> sharing your location sends them a map link. No location? No problem — the page still works.</p>
<button id="recheck-btn" type="button" class="mt-2 rounded bg-amber-500 px-3 py-1.5 text-xs font-semibold text-stone-900 hover:bg-amber-400">📍 Re-check my location</button>
</div>
<div id="contact-area" class="mt-4">
<form hx-post="/t/{{.Data.TagCode}}/contact" hx-target="#contact-area" hx-swap="innerHTML" class="flex gap-2">
<input name="phone" type="tel" placeholder="Leave your number for the owner (optional)" class="flex-1 rounded border border-stone-300 px-3 py-2 text-sm" pattern="[0-9+ ]{8,16}">
<button type="submit" class="rounded bg-stone-900 px-4 py-2 text-sm font-semibold text-white hover:bg-stone-700">Send number</button>
</form>
</div>
{{if .Data.IsOwner}}
<p class="mt-4 text-center text-sm"><a href="/account/tags/{{.Data.ID}}/edit" class="text-amber-600 hover:underline">✏️ Edit this tag's details</a></p>
{{end}}
</div>
</div>
<script>
(function () {
var code = {{.Data.TagCode | printf "%q"}};
var btn = document.getElementById("recheck-btn");
function sendScan(lat, lng) {
fetch("/t/" + code + "/scan", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify(lat != null ? {lat: lat, lng: lng} : {})
});
}
function tryLocation() {
if (!navigator.geolocation) { sendScan(null, null); return; }
navigator.geolocation.getCurrentPosition(
function (pos) {
if (btn) btn.classList.add("hidden");
sendScan(pos.coords.latitude, pos.coords.longitude);
},
function () { sendScan(null, null); },
{timeout: 8000, maximumAge: 60000}
);
}
if (btn) btn.addEventListener("click", tryLocation);
tryLocation();
})();
</script>
{{end}}
</div>
{{end}}