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:
2
Makefile
2
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
|
||||
|
||||
11
README.md
11
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/`
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
194
frontend/internal/handlers/scan.go
Normal file
194
frontend/internal/handlers/scan.go
Normal 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)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
16
frontend/internal/sms/geo.go
Normal file
16
frontend/internal/sms/geo.go
Normal 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))
|
||||
}
|
||||
21
frontend/internal/sms/normalize.go
Normal file
21
frontend/internal/sms/normalize.go
Normal 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
|
||||
}
|
||||
9
frontend/internal/sms/sender.go
Normal file
9
frontend/internal/sms/sender.go
Normal 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
|
||||
}
|
||||
52
frontend/internal/sms/sms_test.go
Normal file
52
frontend/internal/sms/sms_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
85
frontend/internal/sms/smsglobal.go
Normal file
85
frontend/internal/sms/smsglobal.go
Normal 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)
|
||||
}
|
||||
13
frontend/internal/sms/smslog.go
Normal file
13
frontend/internal/sms/smslog.go
Normal 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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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}}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -1,27 +1,27 @@
|
||||
## 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)
|
||||
- [ ] 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.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.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.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.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
|
||||
- [ ] 2.3 Wire `internal/sms` sender into `App` (log sender when `SMS_API_KEY` unset) + register routes in `main.go`
|
||||
- [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`
|
||||
- [x] 2.2 `POST /t/{tag_code}/contact` — validate finder number, store on latest scan, SMS the owner with the finder's number
|
||||
- [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.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.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.2 Branding sweep: "WhereWoof" → "Where Woof" in all templates + README; `<title>` → "Where Woof !"
|
||||
|
||||
## 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.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.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.2 Unit test haversine (known distances) + NormalizeAU cases
|
||||
- [ ] 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
56
plans/scan-flow.md
Normal 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.
|
||||
Reference in New Issue
Block a user