Files
where_woof/openspec/changes/scan-flow/design.md

68 lines
4.8 KiB
Markdown

## Context
Phase 1 (frontend-foundation, merged) gives us auth, tag setup/management, and the public tag page with `tel:` links. Phase 2 adds the finder-facing scan flow and owner alerts. Confirmed with the owner: SMS provider is **SMSGlobal** (MXT), using their **shared/pooled sender number** (origin blank — upgrade to a registered sender later); real test number `+61432374487`. The `scans` table already exists; it needs no new columns.
## Goals / Non-Goals
**Goals:**
- Every scan recorded; finder location captured when permitted.
- Owner alerted by SMS (SMSGlobal) with a useful, location-aware message.
- Cost/spam protection: 250 m / 10-minute per-tag throttle; `sms_enabled` kill-switch per tag.
- Finder can leave a number; owner is told.
- Branding: "Where Woof" / "Where Woof !".
**Non-Goals:**
- Registered sender ID / short-code upgrades — later (pooled number now).
- Scan-limit plans/billing enforcement — Phase 5.
- Email alerts, NTFY, admin scan dashboard — later phases.
- Inbound SMS (owner replying to finder) — later.
## Decisions
1. **Geolocation: vanilla JS on `tag-public.html` (no framework).**
On load (active tag only, HTTPS), `navigator.geolocation.getCurrentPosition`; on success `fetch POST /t/{code}/scan {lat,lng}`; on error/deny, POST without coords and show the **re-check button**; on success hide it. No Alpine needed — this is one small script. *Alternative:* Alpine `x-init` — rejected, plain JS is clearer for one flow.
2. **`internal/sms` package with a `Sender` interface.**
```go
type Sender interface { Send(to, body string) error }
```
Implementations: `smsglobal.Client` (HTTP POST to `api.smsglobal.com` REST API, API key + secret auth, JSON body; **exact field names confirmed against the owner's MXT docs at build time** — they have an existing integration) and `smslog.Sender` (logs; used when `SMS_API_KEY` is unset, so dev/tests never send real SMS). Numbers normalised via `sms.NormalizeAU` (strip `+`/spaces; leading `0` → `61`).
3. **Location-aware throttle (the interesting rule).**
On each scan POST: fetch the tag's most recent scan that had an alert (`alert_sent = true`). If none, or older than 10 minutes → alert (if `sms_enabled`). If within 10 minutes: alert only when the new scan has coordinates **and** haversine distance from the last alerted location **> 250 m**. Otherwise record the scan, no alert. Alert outcome written to `alert_sent` on the new row. Haversine helper lives in `internal/sms` (pure function, unit-testable).
4. **`sms_enabled` column via idempotent migration.**
`db/schema.sql` gains `ALTER TABLE tags ADD COLUMN IF NOT EXISTS sms_enabled BOOLEAN NOT NULL DEFAULT TRUE;` — re-applying to the existing dev DB migrates cleanly (spec: database). sqlc regenerated; `UpdateTagDetails` gains the flag; edit form gets a checkbox.
5. **Message template** (SMS ≤ 160 chars):
```
Where Woof: your {Dog}, {Shadow} was scanned at {3:05 pm}.
Location: https://maps.google.com/?q={lat},{lng}
See: https://where-woof.com/t/{TEST000001}
```
Name = `description` truncated to ~30 chars; item type title-cased. No location → omit the Location line and append "Finder didn't share a location." (first scan only — throttle suppresses repeats).
6. **Secrets via env only.** `SMS_API_KEY`, `SMS_API_SECRET`, optional `SMS_FROM` (blank → pooled number). Dev uses the log sender automatically.
7. **Branding** is a sweep: `WhereWoof` → `Where Woof` in templates/README/title (`Where Woof !`). No functional impact.
## Risks / Trade-offs
- [SMSGlobal REST field names/version uncertain] → confirm against the owner's existing MXT integration during execution; keep the Sender interface thin so swapping is trivial.
- [Pooled sender number may be unreliable/delayed for AU delivery] → accepted; registered sender is a known upgrade path.
- [Geolocation requires HTTPS] → dev on `localhost` is exempt by browsers; prod goes behind Caddy with TLS before this is exposed publicly.
- [Finder location is private data] → stored in `scans` (lat/lng), only surfaced to the owner via the SMS; revisit privacy wording when public.
- [SMS cost abuse] → throttle + `sms_enabled` cap it; plan-level scan limits arrive in Phase 5.
- [160-char limit with maps link] → maps link shortened (`https://maps.google.com/?q=lat,lng` is ~40 chars); template budgeted.
## Migration Plan
1. Re-apply `db/schema.sql` (idempotent ALTER) on the existing dev DB; regenerate sqlc queries.
2. Land code with log sender; run automated suite (no real SMS).
3. Manual check: one real SMS to `+61432374487` with real credentials from `.13`-safe env (local run, then removed).
## Open Questions
- Exact SMSGlobal REST endpoint/field names for the owner's account (confirm at build time).
- Whether `description` doubles as the item "name" in the SMS (assumed yes, truncated) or a dedicated name field arrives with the Laravel admin.