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 .
|
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
|
||||||
|
|||||||
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
|
## 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/`
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
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"))),
|
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)
|
||||||
|
|||||||
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/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)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}}
|
||||||
|
|||||||
@@ -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
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