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

@@ -19,7 +19,7 @@ build: ## build linux amd64 binary for .13
cd frontend && GOOS=linux GOARCH=amd64 go build -o where-woof . cd frontend && GOOS=linux GOARCH=amd64 go build -o where-woof .
generate: ## regenerate sqlc query code generate: ## regenerate sqlc query code
cd frontend && sqlc generate cd frontend && PATH="$$PATH:$$(go env GOPATH)/bin" sqlc generate
psql: ## ad-hoc SQL shell into wherewoof-db on .13 (no local psql install) psql: ## ad-hoc SQL shell into wherewoof-db on .13 (no local psql install)
ssh sam@192.168.20.13 docker exec -i wherewoof-db psql -U wherewoof -d wherewoof ssh sam@192.168.20.13 docker exec -i wherewoof-db psql -U wherewoof -d wherewoof

View File

@@ -1,11 +1,12 @@
# WhereWoof — Local Pet Location Tracker # Where Woof — Return-Tag Platform
Find your woof. A local pet location tracker with a front-facing website for pet owners (GOAT stack: Go + HTMX + Alpine.js + Tailwind CSS + SQLite). Find your woof. A return-item tag platform (PetHub/ReturnMe style) — *"Where Woof !"* (a play on *werewolf*). Pre-coded QR/NFC tags on your dog, baggage, skis, etc. A finder scans the tag and sees exactly how to return it — call, text, or leave their number; location is shared with the owner when the finder allows.
## Status ## Status
Early design/development phase. GOAT front-end (Phase 1) built and verified — see `where_woof.md` for the full plan and roadmap.
- Design docs: [where_woof.md](where_woof.md), [awesome-design.md](awesome-design.md) - Plan: [where_woof.md](where_woof.md), [awesome-design.md](awesome-design.md)
- Specs & changes: `openspec/` (frontend-foundation ✅, scan-flow 🚧)
- Front-end: `frontend/` (Go + HTMX + Alpine + Tailwind + Postgres)
- Mockups: `mockups/` - Mockups: `mockups/`
- Admin & frontend prototypes: `admin/`, `frontend/`

View File

@@ -37,3 +37,6 @@ CREATE TABLE IF NOT EXISTS scans (
scanner_phone TEXT, scanner_phone TEXT,
alert_sent BOOLEAN NOT NULL DEFAULT FALSE alert_sent BOOLEAN NOT NULL DEFAULT FALSE
); );
-- Phase 2: per-tag SMS alert control. Idempotent so existing DBs migrate cleanly.
ALTER TABLE tags ADD COLUMN IF NOT EXISTS sms_enabled BOOLEAN NOT NULL DEFAULT TRUE;

View File

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

View File

@@ -15,12 +15,17 @@ type Querier interface {
ClearTagOwner(ctx context.Context, id int64) (Tag, error) ClearTagOwner(ctx context.Context, id int64) (Tag, error)
CountTagsByOwner(ctx context.Context, ownerID pgtype.Int8) (int64, error) CountTagsByOwner(ctx context.Context, ownerID pgtype.Int8) (int64, error)
CreateUser(ctx context.Context, arg CreateUserParams) (User, 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) 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)
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
SetScanPhone(ctx context.Context, arg SetScanPhoneParams) error
SetTagStatus(ctx context.Context, arg SetTagStatusParams) error SetTagStatus(ctx context.Context, arg SetTagStatusParams) error
UpdateTagDetails(ctx context.Context, arg UpdateTagDetailsParams) (Tag, error) UpdateTagDetails(ctx context.Context, arg UpdateTagDetailsParams) (Tag, error)
} }

View File

@@ -29,7 +29,7 @@ RETURNING *;
-- name: UpdateTagDetails :one -- name: UpdateTagDetails :one
UPDATE tags 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 WHERE id=$1
RETURNING *; RETURNING *;
@@ -45,3 +45,26 @@ UPDATE tags SET status=$2, updated_at=now() WHERE id=$1;
-- name: InsertTag :one -- name: InsertTag :one
INSERT INTO tags (tag_code) VALUES ($1) INSERT INTO tags (tag_code) VALUES ($1)
RETURNING *; 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 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 RETURNING id, tag_code, owner_id, status, item_type, description, photo_url, phone, address, notes, created_at, updated_at, sms_enabled
` `
type BindTagParams struct { type BindTagParams struct {
@@ -39,6 +39,7 @@ func (q *Queries) BindTag(ctx context.Context, arg BindTagParams) (Tag, error) {
&i.Notes, &i.Notes,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
&i.SmsEnabled,
) )
return i, err return i, err
} }
@@ -47,7 +48,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 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) { 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.Notes,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
&i.SmsEnabled,
) )
return i, err return i, err
} }
@@ -113,8 +115,54 @@ func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, e
return i, err 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 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) { 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.Notes,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
&i.SmsEnabled,
) )
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 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) { 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.Notes,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
&i.SmsEnabled,
) )
return i, err return i, err
} }
@@ -197,9 +247,45 @@ func (q *Queries) GetUserByID(ctx context.Context, id int64) (User, error) {
return i, err 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 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 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) { 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.Notes,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
&i.SmsEnabled,
) )
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 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) { 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.Notes,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
&i.SmsEnabled,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@@ -259,6 +347,34 @@ func (q *Queries) ListTagsByOwner(ctx context.Context, ownerID pgtype.Int8) ([]T
return items, nil 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 const setTagStatus = `-- name: SetTagStatus :exec
UPDATE tags SET status=$2, updated_at=now() WHERE id=$1 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 const updateTagDetails = `-- name: UpdateTagDetails :one
UPDATE tags 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 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 { type UpdateTagDetailsParams struct {
@@ -288,6 +404,7 @@ type UpdateTagDetailsParams struct {
Phone pgtype.Text `json:"phone"` Phone pgtype.Text `json:"phone"`
Address pgtype.Text `json:"address"` Address pgtype.Text `json:"address"`
Notes pgtype.Text `json:"notes"` Notes pgtype.Text `json:"notes"`
SmsEnabled bool `json:"sms_enabled"`
} }
func (q *Queries) UpdateTagDetails(ctx context.Context, arg UpdateTagDetailsParams) (Tag, error) { 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.Phone,
arg.Address, arg.Address,
arg.Notes, arg.Notes,
arg.SmsEnabled,
) )
var i Tag var i Tag
err := row.Scan( err := row.Scan(
@@ -314,6 +432,7 @@ func (q *Queries) UpdateTagDetails(ctx context.Context, arg UpdateTagDetailsPara
&i.Notes, &i.Notes,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
&i.SmsEnabled,
) )
return i, err return i, err
} }

View File

@@ -9,6 +9,7 @@ import (
"wherewoof/frontend/internal/auth" "wherewoof/frontend/internal/auth"
"wherewoof/frontend/internal/db" "wherewoof/frontend/internal/db"
"wherewoof/frontend/internal/sms"
) )
// Templates maps a page key to its parsed template set (base + page + partials). // 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 { type App struct {
Queries *db.Queries Queries *db.Queries
Tpl Templates Tpl Templates
Sender sms.Sender
} }
// New returns an App with the given query layer and template sets. // New returns an App with the given query layer, template sets, and SMS sender.
func New(queries *db.Queries, tpl Templates) *App { func New(queries *db.Queries, tpl Templates, sender sms.Sender) *App {
return &App{Queries: queries, Tpl: tpl} return &App{Queries: queries, Tpl: tpl, Sender: sender}
} }
// PageData is the root data passed to the base layout. // 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"))), Phone: textOrNil(strings.TrimSpace(r.FormValue("phone"))),
Address: textOrNil(strings.TrimSpace(r.FormValue("address"))), Address: textOrNil(strings.TrimSpace(r.FormValue("address"))),
Notes: textOrNil(strings.TrimSpace(r.FormValue("notes"))), Notes: textOrNil(strings.TrimSpace(r.FormValue("notes"))),
SmsEnabled: r.FormValue("sms_enabled") == "on",
} }
if _, err := a.Queries.UpdateTagDetails(r.Context(), params); err != nil { if _, err := a.Queries.UpdateTagDetails(r.Context(), params); err != nil {
http.Error(w, "internal error", http.StatusInternalServerError) 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/auth"
"wherewoof/frontend/internal/db" "wherewoof/frontend/internal/db"
"wherewoof/frontend/internal/handlers" "wherewoof/frontend/internal/handlers"
"wherewoof/frontend/internal/sms"
) )
func main() { func main() {
@@ -42,13 +43,21 @@ func main() {
log.Fatal("templates:", err) 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 := http.NewServeMux()
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.Dir("static")))) mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.Dir("static"))))
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("POST /t/{tag_code}/scan", app.Scan)
mux.HandleFunc("POST /t/{tag_code}/contact", app.FinderContact)
mux.HandleFunc("GET /register", app.RegisterPage) mux.HandleFunc("GET /register", app.RegisterPage)
mux.HandleFunc("POST /register", app.Register) mux.HandleFunc("POST /register", app.Register)
mux.HandleFunc("GET /login", app.LoginPage) mux.HandleFunc("GET /login", app.LoginPage)

View File

@@ -4,7 +4,7 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <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://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/htmx.org@1.9.12"></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> <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"> <body class="min-h-full bg-stone-100 text-stone-900 antialiased">
<nav class="bg-stone-900 text-white"> <nav class="bg-stone-900 text-white">
<div class="mx-auto flex max-w-4xl items-center justify-between px-4 py-3"> <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"> <div class="flex items-center gap-4 text-sm">
{{if .CurrentUser}} {{if .CurrentUser}}
<span class="text-stone-300">Hi, {{.CurrentUser.Name}}</span> <span class="text-stone-300">Hi, {{.CurrentUser.Name}}</span>

View File

@@ -2,7 +2,7 @@
<div class="mx-auto max-w-2xl text-center"> <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> <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"> <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. A finder scans the QR code or taps the NFC tag and sees exactly how to get it back to you.
</p> </p>

View File

@@ -1,7 +1,7 @@
{{define "content"}} {{define "content"}}
<div class="mx-auto max-w-md"> <div class="mx-auto max-w-md">
<h1 class="text-2xl font-bold">Log in</h1> <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}} {{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> <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> </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> </div>
{{end}} {{end}}

View File

@@ -2,7 +2,7 @@
<div class="mx-auto max-w-md text-center"> <div class="mx-auto max-w-md text-center">
<div class="text-5xl">🔎</div> <div class="text-5xl">🔎</div>
<h1 class="mt-4 text-2xl font-bold">Tag not found</h1> <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> <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> </div>
{{end}} {{end}}

View File

@@ -1,7 +1,7 @@
{{define "content"}} {{define "content"}}
<div class="mx-auto max-w-md"> <div class="mx-auto max-w-md">
<h1 class="text-2xl font-bold">Create your account</h1> <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}} {{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> <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> <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> </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"> <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> <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> <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="rounded-xl border border-stone-200 bg-white p-8 text-center shadow-sm">
<div class="text-5xl">🏷️</div> <div class="text-5xl">🏷️</div>
<h1 class="mt-4 text-2xl font-bold">This tag isn't set up yet</h1> <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"> <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="/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> <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"> <h1 class="text-2xl font-bold">
{{if .Data.ItemType.Valid}}{{.Data.ItemType.String | title}}{{else}}Item{{end}} — found! {{if .Data.ItemType.Valid}}{{.Data.ItemType.String | title}}{{else}}Item{{end}} — found!
</h1> </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>
<div class="p-6"> <div class="p-6">
{{if .Data.PhotoUrl.Valid}} {{if .Data.PhotoUrl.Valid}}
@@ -37,6 +37,10 @@
<dt class="font-medium">Call the owner</dt> <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> <dd><a href="tel:{{.Data.Phone.String}}" class="font-semibold text-amber-600 hover:underline">{{.Data.Phone.String}}</a></dd>
</div> </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}} {{end}}
{{if .Data.Address.Valid}} {{if .Data.Address.Valid}}
<div class="flex items-center justify-between rounded bg-stone-50 px-4 py-3"> <div class="flex items-center justify-between rounded bg-stone-50 px-4 py-3">
@@ -51,14 +55,51 @@
</div> </div>
{{end}} {{end}}
</dl> </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! <div class="mt-4 rounded border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
</p> <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}} {{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> <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}} {{end}}
</div> </div>
</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}} {{end}}
</div> </div>
{{end}} {{end}}

View File

@@ -1,27 +1,27 @@
## 1. Schema & Queries ## 1. Schema & Queries
- [ ] 1.1 Add idempotent `ALTER TABLE tags ADD COLUMN IF NOT EXISTS sms_enabled BOOLEAN NOT NULL DEFAULT TRUE;` to `db/schema.sql`; run `make db-up` (migrates existing DB) - [x] 1.1 Add idempotent `ALTER TABLE tags ADD COLUMN IF NOT EXISTS sms_enabled BOOLEAN NOT NULL DEFAULT TRUE;` to `db/schema.sql`; run `make db-up` (migrates existing DB)
- [ ] 1.2 Add sqlc queries in `internal/db/queries.sql`: `InsertScan` (tag_id, lat/lng nullable, location_shared, scanner_phone nullable), `GetLastAlertByTag` (latest scan with alert_sent=true), `UpdateTagDetails` extended with `sms_enabled`; `make generate` - [x] 1.2 Add sqlc queries in `internal/db/queries.sql`: `InsertScan` (tag_id, lat/lng nullable, location_shared, scanner_phone nullable), `GetLastAlertByTag` (latest scan with alert_sent=true), `UpdateTagDetails` extended with `sms_enabled`; `make generate`
- [ ] 1.3 New `internal/sms` package: `Sender` interface, `sms.NormalizeAU`, `sms.HaversineMeters`; `smslog` sender; `smsglobal` HTTP client (REST, key+secret, JSON; field names confirmed with MXT docs) - [x] 1.3 New `internal/sms` package: `Sender` interface, `sms.NormalizeAU`, `sms.HaversineMeters`; `smslog` sender; `smsglobal` HTTP client (REST, key+secret, JSON; field names confirmed with MXT docs)
## 2. Scan Handlers ## 2. Scan Handlers
- [ ] 2.1 `internal/handlers/scan.go`: `POST /t/{tag_code}/scan` — parse optional lat/lng, record scan, apply 250 m / 10-min throttle, send SMS per rules, set `alert_sent` - [x] 2.1 `internal/handlers/scan.go`: `POST /t/{tag_code}/scan` — parse optional lat/lng, record scan, apply 250 m / 10-min throttle, send SMS per rules, set `alert_sent`
- [ ] 2.2 `POST /t/{tag_code}/contact` — validate finder number, store on latest scan, SMS the owner with the finder's number - [x] 2.2 `POST /t/{tag_code}/contact` — validate finder number, store on latest scan, SMS the owner with the finder's number
- [ ] 2.3 Wire `internal/sms` sender into `App` (log sender when `SMS_API_KEY` unset) + register routes in `main.go` - [x] 2.3 Wire `internal/sms` sender into `App` (log sender when `SMS_API_KEY` unset) + register routes in `main.go`
## 3. Public Page JS & Templates ## 3. Public Page JS & Templates
- [ ] 3.1 `tag-public.html`: geolocation script (prompt on load, POST scan with coords, re-check button shown until shared, hidden after), `sms:` link with prefilled body, finder contact form + inline error - [x] 3.1 `tag-public.html`: geolocation script (prompt on load, POST scan with coords, re-check button shown until shared, hidden after), `sms:` link with prefilled body, finder contact form + inline error
- [ ] 3.2 Branding sweep: "WhereWoof" → "Where Woof" in all templates + README; `<title>` → "Where Woof !" - [x] 3.2 Branding sweep: "WhereWoof" → "Where Woof" in all templates + README; `<title>` → "Where Woof !"
## 4. Tag Management ## 4. Tag Management
- [ ] 4.1 `tag-edit.html` + `tags.go` edit handler: `sms_enabled` checkbox (default checked), saved via `UpdateTagDetails` - [x] 4.1 `tag-edit.html` + `tags.go` edit handler: `sms_enabled` checkbox (default checked), saved via `UpdateTagDetails`
## 5. Verification ## 5. Verification
- [ ] 5.1 Extend the HTTP suite (`/tmp/verify.sh`): scan with location → scan row + alert logged + `alert_sent=true`; same-location re-scan in window → no second alert; >250 m re-scan in window → second alert; after 10 min → alert; `sms_enabled=false` → no alert; finder contact → stored + owner alert; `sms:` link and re-check button present; branding strings present - [x] 5.1 Extend the HTTP suite (`/tmp/verify.sh`): scan with location → scan row + alert logged + `alert_sent=true`; same-location re-scan in window → no second alert; >250 m re-scan in window → second alert; after 10 min → alert; `sms_enabled=false` → no alert; finder contact → stored + owner alert; `sms:` link and re-check button present; branding strings present
- [ ] 5.2 Unit test haversine (known distances) + NormalizeAU cases - [x] 5.2 Unit test haversine (known distances) + NormalizeAU cases
- [ ] 5.3 Manual real-SMS check to `+61432374487` (real credentials, then removed) - [ ] 5.3 Manual real-SMS check to `+61432374487` (real credentials, then removed)
- [ ] 5.4 `openspec validate scan-flow`; commit - [x] 5.4 `openspec validate scan-flow`; commit

56
plans/scan-flow.md Normal file
View File

@@ -0,0 +1,56 @@
# Phase 2 — Scan Flow (geolocation → SMS alert, finder contact, branding)
Plan for OpenSpec change `scan-flow` (proposal/design/specs/tasks in `openspec/changes/scan-flow/`).
## Context
Phase 1 (merged) gives us auth, tag setup/management, and the public tag page with `tel:` links. Phase 2 delivers the core product moment: a finder scans a lost item's tag, the scan (with location when permitted) is recorded, and the **owner is alerted by SMS** (SMSGlobal, pooled sender number) with item type + name, time, maps link, and tag page link. A finder can also leave their number for the owner. Branding: "WhereWoof" → **"Where Woof"** (werewolf wordplay), title **"Where Woof !"**.
All decisions are settled in the change (proposal/design): SMSGlobal REST, **pooled number** (origin blank), real test number **+61432374487**, message includes item type + name (description truncated ~30 chars), finder number stored in `scans.scanner_phone` AND SMS'd to owner, **location-aware throttle** (within 10-min window per tag, re-alert only if new location is >250 m from last alerted location; same spot = recorded, no SMS), and **`sms_enabled` per tag** (some tags are purely informational — default on, toggle on the edit form).
## Approach
- **`internal/sms` package** (new):
- `Sender` interface (`Send(to, body string) error`) — the seam for tests.
- `smslog` sender (logs; used automatically when `SMS_API_KEY` is unset — dev/tests never send real SMS).
- `smsglobal` HTTP client (POST `api.smsglobal.com` REST, API key+secret auth, JSON; **field names confirmed against the owner's MXT docs at build time** — they have a prior integration).
- `NormalizeAU` (strip `+`/spaces; leading `0``61`) + `HaversineMeters` (pure, unit-tested).
- **`handlers/scan.go`** (new): `POST /t/{tag_code}/scan` — parse optional lat/lng, `InsertScan`, apply throttle, send alert per rules, set `alert_sent`. `POST /t/{tag_code}/contact` — validate finder number, store on latest scan, SMS the owner with it.
- **Public page** (`tag-public.html`, active branch only): vanilla JS geolocation (prompt on load over HTTPS, `fetch POST /t/{code}/scan` with coords; on deny POST without coords and show **re-check button**; hide once shared), `sms:` link (prefilled body asking for the finder's number), **finder contact form** with inline error.
- **Schema**: idempotent `ALTER TABLE tags ADD COLUMN IF NOT EXISTS sms_enabled BOOLEAN NOT NULL DEFAULT TRUE;` in `db/schema.sql`; `make db-up` migrates the existing DB. New sqlc queries: `InsertScan`, `GetLastAlertByTag` (latest scan with `alert_sent=true`), `UpdateTagDetails` extended with `sms_enabled`. `make generate`.
- **Tag management**: `sms_enabled` checkbox in `tag-edit.html` (default checked), saved via `UpdateTagDetails`.
- **Branding sweep**: `WhereWoof``Where Woof` in templates + README; `<title>``Where Woof !`.
- **Wiring**: `App` gains a `Sender`; `main.go` picks log vs smsglobal from env (`SMS_API_KEY`/`SMS_API_SECRET`/`SMS_FROM`); 2 new routes.
## Files to create / modify
- New: `frontend/internal/sms/sender.go`, `smslog.go`, `smsglobal.go`, `geo.go`, `geo_test.go`, `normalize_test.go`; `frontend/internal/handlers/scan.go`
- Modified: `db/schema.sql`; `frontend/internal/db/queries.sql` (+ regenerated `db.sql.go`, `models.go`, `querier.go`); `frontend/internal/handlers/handlers.go` (App + Sender), `tags.go` (sms_enabled), `main.go` (sender + routes); `frontend/templates/tag-public.html`, `tag-edit.html`, `base.html` (title); `README.md`
- Secrets (never in repo): `SMS_API_KEY`, `SMS_API_SECRET`, `SMS_FROM` (blank = pooled) — env vars / `~/.config/environment.d/10-secrets.conf` pattern
## Reuse
- `scans` table already exists (schema.sql) — no new tables.
- `tel:` link pattern from Phase 1 for the `sms:` link.
- `account-panel` HTMX pattern; `textOrNil`/`ownerID` helpers in `handlers`.
- `/tmp/verify.sh` — extend with scan scenarios (log-sender asserts, no real SMS).
- `make db-up` / `make generate` / `make psql` targets.
## Steps
- [x] 1. Schema: `ALTER ... ADD COLUMN IF NOT EXISTS sms_enabled` in `db/schema.sql`; `make db-up` on existing DB
- [x] 2. sqlc: add `InsertScan`, `GetLastAlertByTag`, `UpdateTagDetails`+`sms_enabled`; `make generate`
- [x] 3. `internal/sms`: Sender interface, `smslog`, `smsglobal` client (field names from MXT docs), `NormalizeAU`, `HaversineMeters` + unit tests
- [x] 4. `handlers/scan.go`: scan handler (record → throttle → alert → `alert_sent`) + contact handler; wire Sender into App + routes in `main.go`
- [x] 5. Public page: geolocation JS (prompt, POST coords, re-check button, hide after share), `sms:` link, finder contact form
- [x] 6. Tag management: `sms_enabled` checkbox in edit form + handler
- [x] 7. Branding sweep: "Where Woof" / "Where Woof !" across templates + README
- [x] 8. Extend `/tmp/verify.sh` (scan with/without location, same-spot throttle, >250 m re-alert, post-window alert, `sms_enabled=false`, finder contact, `sms:` link, re-check button, branding); run full suite
- [ ] 9. Manual real-SMS check to `+61432374487` (real creds, then remove); `openspec validate scan-flow`; commit
## Verification
- Unit: haversine known distances; `NormalizeAU` (+61432374487 / 0432374487 → 61432374487).
- HTTP suite (log sender): scan with location → scan row + alert logged + `alert_sent=true`; same-location re-scan within 10 min → recorded, no alert; >250 m re-scan within window → second alert; scan after window → alert; `sms_enabled=false` → no alert; finder contact → stored + owner alert; `sms:` link, re-check button present; button hidden after share; branding strings present.
- Manual: one real SMS to +61432374487 with real credentials.
- `openspec validate scan-flow`; commit all.