openspec: scan-flow change — proposal, design, 5 specs (3 new + 2 deltas), tasks
This commit is contained in:
2
openspec/changes/scan-flow/.openspec.yaml
Normal file
2
openspec/changes/scan-flow/.openspec.yaml
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-08-05
|
||||||
3
openspec/changes/scan-flow/README.md
Normal file
3
openspec/changes/scan-flow/README.md
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# scan-flow
|
||||||
|
|
||||||
|
Scan flow: geolocation -> SMS alert (SMSGlobal, location-aware throttle), finder contact, sms: link, sms_enabled per tag, branding
|
||||||
67
openspec/changes/scan-flow/design.md
Normal file
67
openspec/changes/scan-flow/design.md
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
## 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.
|
||||||
37
openspec/changes/scan-flow/proposal.md
Normal file
37
openspec/changes/scan-flow/proposal.md
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
## Why
|
||||||
|
|
||||||
|
Phase 1 built the foundation (auth, tag setup, public page), but the core product moment is missing: a finder scans a lost item's tag and the **owner is alerted** so the item can be returned. Phase 2 delivers the scan flow — geolocation on scan, SMS alert to the owner via SMSGlobal, a finder-contact channel, and the branding polish ("Where Woof", werewolf wordplay).
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- **Scan recording**: `POST /t/{tag_code}/scan` records every scan (with or without location) into the existing `scans` table.
|
||||||
|
- **Geolocation flow** (vanilla JS on the public tag page): prompt for location on load (HTTPS), send coordinates with the scan, **re-check location** button, button hidden once shared.
|
||||||
|
- **SMS alerts to owner (SMSGlobal)**: on scan of an `sms_enabled` tag, send the owner an SMS with item type + name, time, maps link, and tag page link. No location shared → alert still sent (first time) with "no location" wording.
|
||||||
|
- **Location-aware throttle**: within a 10-minute window per tag, only re-send if the new location is **>250 m** from the last alerted location. Same spot → record the scan but do not re-alert.
|
||||||
|
- **`sms_enabled` per tag**: new column (default true); owners can switch a tag to purely informational (no SMS at all) from the edit form.
|
||||||
|
- **Finder contact**: finder can leave their mobile → stored in `scans.scanner_phone` → owner is SMS'd with the finder's number.
|
||||||
|
- **`sms:` link** on the tag page (prefilled message asking the finder for their number).
|
||||||
|
- **Branding**: "WhereWoof" → **"Where Woof"**, page title **"Where Woof !"** (play on *werewolf*).
|
||||||
|
- Provider detail (confirmed with owner): SMSGlobal, **pooled/shared sender number** (origin blank; upgrade to registered sender later), real test number `+61432374487`.
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
|
||||||
|
- `scan-flow`: geolocation prompt, scan recording, re-check button, hide-after-share, `sms:` link.
|
||||||
|
- `sms-alerting`: SMSGlobal integration behind a swappable `Sender` interface, message template, location-aware throttle (250 m / 10 min), `sms_enabled` respected, `alert_sent` tracking.
|
||||||
|
- `finder-contact`: finder phone input → stored → owner notified.
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
|
||||||
|
- `tag-management`: ADDED — per-tag `sms_enabled` toggle on the edit form (default on).
|
||||||
|
- `database`: ADDED — `tags.sms_enabled` column (idempotent `ALTER TABLE ... ADD COLUMN IF NOT EXISTS`, default TRUE).
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- **New Go**: `internal/sms` (Sender interface, `smsglobal` HTTP client, `log` fake, haversine distance), `internal/handlers/scan.go`.
|
||||||
|
- **Modified Go**: `internal/handlers/tags.go` (sms_enabled in edit), `internal/db/queries.sql` (InsertScan, GetLastScan, UpdateTagDetails + sms_enabled), `main.go` (2 routes + sender wiring).
|
||||||
|
- **Modified templates**: `tag-public.html` (geolocation JS, sms: link, contact form), `tag-edit.html` (sms_enabled checkbox), all templates + README (branding).
|
||||||
|
- **Schema**: `db/schema.sql` gains idempotent `sms_enabled` column.
|
||||||
|
- **Secrets (never in repo)**: `SMS_API_KEY`, `SMS_API_SECRET` (SMSGlobal MXT), optional `SMS_FROM` (blank = pooled number). No key set → app uses the log sender (safe dev default).
|
||||||
|
- **No changes** to auth, deployment, or billing phases.
|
||||||
12
openspec/changes/scan-flow/specs/database/spec.md
Normal file
12
openspec/changes/scan-flow/specs/database/spec.md
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: sms_enabled column
|
||||||
|
The `tags` table SHALL include an `sms_enabled` boolean column, NOT NULL with default TRUE. The schema SHALL add it idempotently so existing databases migrate cleanly.
|
||||||
|
|
||||||
|
#### Scenario: Fresh database
|
||||||
|
- **WHEN** `db/schema.sql` is applied to an empty database
|
||||||
|
- **THEN** `tags.sms_enabled` exists, defaulting to TRUE
|
||||||
|
|
||||||
|
#### Scenario: Existing database
|
||||||
|
- **WHEN** `db/schema.sql` is re-applied to a database created before this column existed
|
||||||
|
- **THEN** the column is added without error and existing rows default to TRUE
|
||||||
12
openspec/changes/scan-flow/specs/finder-contact/spec.md
Normal file
12
openspec/changes/scan-flow/specs/finder-contact/spec.md
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Finder contact form
|
||||||
|
The active tag page SHALL provide an input for the finder to leave their mobile number. Submitting SHALL store the number on the latest scan (`scans.scanner_phone`) and notify the owner by SMS with the finder's number.
|
||||||
|
|
||||||
|
#### Scenario: Finder leaves a number
|
||||||
|
- **WHEN** the finder submits a valid mobile number
|
||||||
|
- **THEN** the number is stored in `scans.scanner_phone` and the owner receives an SMS containing it
|
||||||
|
|
||||||
|
#### Scenario: Invalid input
|
||||||
|
- **WHEN** the finder submits an empty or malformed number
|
||||||
|
- **THEN** an inline error is shown and nothing is stored
|
||||||
41
openspec/changes/scan-flow/specs/scan-flow/spec.md
Normal file
41
openspec/changes/scan-flow/specs/scan-flow/spec.md
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Geolocation prompt on scan
|
||||||
|
When a visitor opens the page of an active tag over HTTPS, the system SHALL request the visitor's location via the browser geolocation API. Granting SHALL send the coordinates to the server with the scan; denying SHALL NOT block the page.
|
||||||
|
|
||||||
|
#### Scenario: Location granted
|
||||||
|
- **WHEN** the finder allows location on an active tag page
|
||||||
|
- **THEN** the coordinates are posted to the server and recorded with `location_shared = true`
|
||||||
|
|
||||||
|
#### Scenario: Location denied
|
||||||
|
- **WHEN** the finder denies the location request
|
||||||
|
- **THEN** the page still shows the return details and the scan is recorded with `location_shared = false`
|
||||||
|
|
||||||
|
### Requirement: Re-check location
|
||||||
|
The tag page SHALL offer a "re-check location" button while no location has been shared. Activating it SHALL re-request the browser location. Once a location has been shared, the button SHALL be hidden.
|
||||||
|
|
||||||
|
#### Scenario: Re-request after denial
|
||||||
|
- **WHEN** the finder initially denied and presses "re-check location"
|
||||||
|
- **THEN** the browser prompts again, and if granted the coordinates are sent to the server
|
||||||
|
|
||||||
|
#### Scenario: Button hidden after share
|
||||||
|
- **WHEN** a location has been successfully shared
|
||||||
|
- **THEN** the re-check button is not displayed
|
||||||
|
|
||||||
|
### Requirement: Scan recorded for every visit
|
||||||
|
Every visit to an active tag page SHALL record a scan row (with or without location), flagged by `location_shared`.
|
||||||
|
|
||||||
|
#### Scenario: Scan with location
|
||||||
|
- **WHEN** a scan is posted with coordinates
|
||||||
|
- **THEN** a `scans` row exists with lat/lng and `location_shared = true`
|
||||||
|
|
||||||
|
#### Scenario: Scan without location
|
||||||
|
- **WHEN** a scan is posted without coordinates
|
||||||
|
- **THEN** a `scans` row exists with `location_shared = false` and null lat/lng
|
||||||
|
|
||||||
|
### Requirement: SMS link to the owner
|
||||||
|
The active tag page SHALL provide an `sms:` link to the owner's number with a prefilled message asking the finder for their phone number.
|
||||||
|
|
||||||
|
#### Scenario: Finder opens the SMS link
|
||||||
|
- **WHEN** the finder taps the "Send SMS" link
|
||||||
|
- **THEN** the device SMS app opens with the owner's number and the prefilled message
|
||||||
48
openspec/changes/scan-flow/specs/sms-alerting/spec.md
Normal file
48
openspec/changes/scan-flow/specs/sms-alerting/spec.md
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Owner alert on scan
|
||||||
|
When a scan of an `sms_enabled` tag is recorded, the system SHALL send an SMS to the tag owner. The message SHALL include the item type and name, the scan time, a maps link when a location was shared, and the tag page link.
|
||||||
|
|
||||||
|
#### Scenario: Alert with location
|
||||||
|
- **WHEN** a scan with coordinates is recorded on an enabled tag outside the throttle window
|
||||||
|
- **THEN** the owner receives an SMS with item type, name, time, maps link, and tag page link, and `alert_sent = true` is recorded
|
||||||
|
|
||||||
|
#### Scenario: Alert without location (first scan)
|
||||||
|
- **WHEN** a scan without coordinates is recorded on an enabled tag outside the throttle window
|
||||||
|
- **THEN** the owner receives an SMS noting the finder did not share a location
|
||||||
|
|
||||||
|
### Requirement: sms_enabled disabled means no alerts
|
||||||
|
When a tag has `sms_enabled = false`, scans SHALL be recorded but no SMS SHALL be sent and `alert_sent` SHALL remain false.
|
||||||
|
|
||||||
|
#### Scenario: Informational tag scanned
|
||||||
|
- **WHEN** a scan is recorded on a tag with `sms_enabled = false`
|
||||||
|
- **THEN** no SMS is sent and `alert_sent = false`
|
||||||
|
|
||||||
|
### Requirement: Location-aware throttle
|
||||||
|
Within a 10-minute window since the last alerted scan of the same tag, the system SHALL send a new alert only if the new scan has a location more than 250 meters from the last alerted location. A scan at the same location SHALL be recorded without a new alert.
|
||||||
|
|
||||||
|
#### Scenario: Same location within window
|
||||||
|
- **WHEN** a second scan of the same tag arrives within 10 minutes at the same location
|
||||||
|
- **THEN** the scan is recorded and no SMS is sent
|
||||||
|
|
||||||
|
#### Scenario: Moved more than 250 m within window
|
||||||
|
- **WHEN** a second scan of the same tag arrives within 10 minutes more than 250 m from the last alerted location
|
||||||
|
- **THEN** a new SMS is sent
|
||||||
|
|
||||||
|
#### Scenario: After the window
|
||||||
|
- **WHEN** a scan arrives more than 10 minutes after the last alert for the tag
|
||||||
|
- **THEN** an SMS is sent regardless of distance
|
||||||
|
|
||||||
|
### Requirement: Swappable SMS sender
|
||||||
|
SMS sending SHALL go through an abstraction so that automated tests and development never send real SMS. With no SMS credentials configured, the app SHALL use a logging sender.
|
||||||
|
|
||||||
|
#### Scenario: No credentials in dev
|
||||||
|
- **WHEN** SMS credentials are not configured
|
||||||
|
- **THEN** sending an alert logs the message instead of calling the provider
|
||||||
|
|
||||||
|
### Requirement: International number format
|
||||||
|
Phone numbers SHALL be normalised to international format without a leading `+` or leading zero before sending (e.g. `+61432374487` or `0432374487` → `61432374487`).
|
||||||
|
|
||||||
|
#### Scenario: Number normalisation
|
||||||
|
- **WHEN** an owner or finder number is stored in any local format
|
||||||
|
- **THEN** the SMS provider receives the normalised international form
|
||||||
12
openspec/changes/scan-flow/specs/tag-management/spec.md
Normal file
12
openspec/changes/scan-flow/specs/tag-management/spec.md
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Per-tag SMS alert toggle
|
||||||
|
The tag edit form SHALL let the owner enable or disable SMS alerts for that tag (`sms_enabled`). The default for new and existing tags SHALL be enabled.
|
||||||
|
|
||||||
|
#### Scenario: Disable alerts
|
||||||
|
- **WHEN** the owner unchecks "Send SMS alerts when scanned" and saves
|
||||||
|
- **THEN** the tag's `sms_enabled` is false and no alerts are sent for future scans (scans are still recorded)
|
||||||
|
|
||||||
|
#### Scenario: Re-enable alerts
|
||||||
|
- **WHEN** the owner checks the option and saves
|
||||||
|
- **THEN** the tag's `sms_enabled` is true and alerts resume per the alerting rules
|
||||||
27
openspec/changes/scan-flow/tasks.md
Normal file
27
openspec/changes/scan-flow/tasks.md
Normal file
@@ -0,0 +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)
|
||||||
|
|
||||||
|
## 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`
|
||||||
|
|
||||||
|
## 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 !"
|
||||||
|
|
||||||
|
## 4. Tag Management
|
||||||
|
|
||||||
|
- [ ] 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
|
||||||
|
- [ ] 5.3 Manual real-SMS check to `+61432374487` (real credentials, then removed)
|
||||||
|
- [ ] 5.4 `openspec validate scan-flow`; commit
|
||||||
Reference in New Issue
Block a user