From c2fa04afd05f3fe00235eedfc7b7e84ffb2243f9 Mon Sep 17 00:00:00 2001 From: Sam Rolfe Date: Wed, 5 Aug 2026 18:23:34 +1000 Subject: [PATCH] =?UTF-8?q?scan-flow:=20geolocation=20scan,=20location-awa?= =?UTF-8?q?re=20SMS=20throttle=20(log=20sender),=20finder=20contact,=20sms?= =?UTF-8?q?=5Fenabled=20toggle,=20branding=20=E2=80=94=2019/19=20phase-2?= =?UTF-8?q?=20scenarios=20pass?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Makefile | 2 +- README.md | 11 +- db/schema.sql | 3 + frontend/internal/db/models.go | 1 + frontend/internal/db/querier.go | 5 + frontend/internal/db/queries.sql | 25 +++- frontend/internal/db/queries.sql.go | 135 ++++++++++++++++- frontend/internal/handlers/handlers.go | 8 +- frontend/internal/handlers/scan.go | 194 +++++++++++++++++++++++++ frontend/internal/handlers/tags.go | 1 + frontend/internal/sms/geo.go | 16 ++ frontend/internal/sms/normalize.go | 21 +++ frontend/internal/sms/sender.go | 9 ++ frontend/internal/sms/sms_test.go | 52 +++++++ frontend/internal/sms/smsglobal.go | 85 +++++++++++ frontend/internal/sms/smslog.go | 13 ++ frontend/main.go | 11 +- frontend/templates/base.html | 4 +- frontend/templates/index.html | 2 +- frontend/templates/login.html | 4 +- frontend/templates/not-found.html | 2 +- frontend/templates/register.html | 2 +- frontend/templates/tag-edit.html | 4 + frontend/templates/tag-public.html | 51 ++++++- openspec/changes/scan-flow/tasks.md | 24 +-- plans/scan-flow.md | 56 +++++++ 26 files changed, 698 insertions(+), 43 deletions(-) create mode 100644 frontend/internal/handlers/scan.go create mode 100644 frontend/internal/sms/geo.go create mode 100644 frontend/internal/sms/normalize.go create mode 100644 frontend/internal/sms/sender.go create mode 100644 frontend/internal/sms/sms_test.go create mode 100644 frontend/internal/sms/smsglobal.go create mode 100644 frontend/internal/sms/smslog.go create mode 100644 plans/scan-flow.md diff --git a/Makefile b/Makefile index eebb27e..9cccef5 100644 --- a/Makefile +++ b/Makefile @@ -19,7 +19,7 @@ build: ## build linux amd64 binary for .13 cd frontend && GOOS=linux GOARCH=amd64 go build -o where-woof . 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) ssh sam@192.168.20.13 docker exec -i wherewoof-db psql -U wherewoof -d wherewoof diff --git a/README.md b/README.md index a829b05..48454cb 100644 --- a/README.md +++ b/README.md @@ -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 -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/` -- Admin & frontend prototypes: `admin/`, `frontend/` diff --git a/db/schema.sql b/db/schema.sql index a39f2b3..96963e6 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -37,3 +37,6 @@ CREATE TABLE IF NOT EXISTS scans ( scanner_phone TEXT, 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; diff --git a/frontend/internal/db/models.go b/frontend/internal/db/models.go index a084100..7ee6e36 100644 --- a/frontend/internal/db/models.go +++ b/frontend/internal/db/models.go @@ -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 { diff --git a/frontend/internal/db/querier.go b/frontend/internal/db/querier.go index d7c7617..51c906a 100644 --- a/frontend/internal/db/querier.go +++ b/frontend/internal/db/querier.go @@ -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) } diff --git a/frontend/internal/db/queries.sql b/frontend/internal/db/queries.sql index 08e4f50..a14d724 100644 --- a/frontend/internal/db/queries.sql +++ b/frontend/internal/db/queries.sql @@ -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; diff --git a/frontend/internal/db/queries.sql.go b/frontend/internal/db/queries.sql.go index 3e980bb..b3a8733 100644 --- a/frontend/internal/db/queries.sql.go +++ b/frontend/internal/db/queries.sql.go @@ -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 } diff --git a/frontend/internal/handlers/handlers.go b/frontend/internal/handlers/handlers.go index 048ccc4..3baf063 100644 --- a/frontend/internal/handlers/handlers.go +++ b/frontend/internal/handlers/handlers.go @@ -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. diff --git a/frontend/internal/handlers/scan.go b/frontend/internal/handlers/scan.go new file mode 100644 index 0000000..5d07148 --- /dev/null +++ b/frontend/internal/handlers/scan.go @@ -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, `

Please enter a valid phone number.

`) + 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, `

Thanks! The owner has been notified with your number.

`) +} + +func writeJSON(w http.ResponseWriter, v any) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(v) +} diff --git a/frontend/internal/handlers/tags.go b/frontend/internal/handlers/tags.go index 4fe8d0b..e1bfc9e 100644 --- a/frontend/internal/handlers/tags.go +++ b/frontend/internal/handlers/tags.go @@ -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) diff --git a/frontend/internal/sms/geo.go b/frontend/internal/sms/geo.go new file mode 100644 index 0000000..1b1a32a --- /dev/null +++ b/frontend/internal/sms/geo.go @@ -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)) +} diff --git a/frontend/internal/sms/normalize.go b/frontend/internal/sms/normalize.go new file mode 100644 index 0000000..2b16413 --- /dev/null +++ b/frontend/internal/sms/normalize.go @@ -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 +} diff --git a/frontend/internal/sms/sender.go b/frontend/internal/sms/sender.go new file mode 100644 index 0000000..506f44f --- /dev/null +++ b/frontend/internal/sms/sender.go @@ -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 +} diff --git a/frontend/internal/sms/sms_test.go b/frontend/internal/sms/sms_test.go new file mode 100644 index 0000000..16ac28b --- /dev/null +++ b/frontend/internal/sms/sms_test.go @@ -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) + } + } +} diff --git a/frontend/internal/sms/smsglobal.go b/frontend/internal/sms/smsglobal.go new file mode 100644 index 0000000..863aeaa --- /dev/null +++ b/frontend/internal/sms/smsglobal.go @@ -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) +} diff --git a/frontend/internal/sms/smslog.go b/frontend/internal/sms/smslog.go new file mode 100644 index 0000000..3ac1740 --- /dev/null +++ b/frontend/internal/sms/smslog.go @@ -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 +} diff --git a/frontend/main.go b/frontend/main.go index 2f83fea..56f4440 100644 --- a/frontend/main.go +++ b/frontend/main.go @@ -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) diff --git a/frontend/templates/base.html b/frontend/templates/base.html index 0c78fbd..74f1c6e 100644 --- a/frontend/templates/base.html +++ b/frontend/templates/base.html @@ -4,7 +4,7 @@ - {{.Title}} · WhereWoof + {{.Title}} · Where Woof ! @@ -12,7 +12,7 @@