sms-metering-location-link: per-tag SMS credits (allocated/used, admin top-up), short URLs + owner location page (map embed + finder details) — 13/13 scenarios

This commit is contained in:
2026-08-08 14:38:32 +10:00
parent db1c358fe7
commit 5487cee966
12 changed files with 297 additions and 37 deletions

View File

@@ -74,3 +74,15 @@ ALTER TABLE users ADD COLUMN IF NOT EXISTS remember_token TEXT;
-- Phase 5-lite: client IP on scans for rate limiting (idempotent).
ALTER TABLE scans ADD COLUMN IF NOT EXISTS ip TEXT;
-- Phase 5.5: per-tag SMS metering (0 = unmetered/transitional).
ALTER TABLE tags ADD COLUMN IF NOT EXISTS sms_allocated INTEGER NOT NULL DEFAULT 0;
ALTER TABLE tags ADD COLUMN IF NOT EXISTS sms_used INTEGER NOT NULL DEFAULT 0;
ALTER TABLE tags ADD COLUMN IF NOT EXISTS sms_period_start DATE NOT NULL DEFAULT CURRENT_DATE;
-- Short URLs for SMS links (owner location pages).
CREATE TABLE IF NOT EXISTS url_shortened (
code TEXT PRIMARY KEY,
scan_id BIGINT NOT NULL REFERENCES scans(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

View File

@@ -54,6 +54,15 @@ type Tag struct {
SmsEnabled bool `json:"sms_enabled"`
ProductID pgtype.Int8 `json:"product_id"`
OrderID pgtype.Int8 `json:"order_id"`
SmsAllocated int32 `json:"sms_allocated"`
SmsUsed int32 `json:"sms_used"`
SmsPeriodStart pgtype.Date `json:"sms_period_start"`
}
type UrlShortened struct {
Code string `json:"code"`
ScanID int64 `json:"scan_id"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
type User struct {

View File

@@ -11,6 +11,7 @@ import (
)
type Querier interface {
AddSmsUsed(ctx context.Context, id int64) error
BindTag(ctx context.Context, arg BindTagParams) (Tag, error)
ClearTagOwner(ctx context.Context, id int64) (Tag, error)
CountAlertsByIPSince(ctx context.Context, arg CountAlertsByIPSinceParams) (int64, error)
@@ -21,11 +22,14 @@ type Querier interface {
GetLatestScanByTag(ctx context.Context, tagID int64) (Scan, error)
GetOrderByID(ctx context.Context, id int64) (Order, error)
GetRecentScanByFingerprint(ctx context.Context, arg GetRecentScanByFingerprintParams) (Scan, error)
GetScanByID(ctx context.Context, id int64) (Scan, error)
GetShortCode(ctx context.Context, code string) (UrlShortened, 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)
InsertShortCode(ctx context.Context, arg InsertShortCodeParams) (UrlShortened, 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

View File

@@ -85,3 +85,15 @@ WHERE tag_id = $1 AND alert_sent = TRUE AND scanned_at > $2;
-- name: CountAlertsByIPSince :one
SELECT count(*) FROM scans
WHERE ip = $1 AND alert_sent = TRUE AND scanned_at > $2;
-- name: AddSmsUsed :exec
UPDATE tags SET sms_used = sms_used + 1, updated_at = now() WHERE id = $1;
-- name: InsertShortCode :one
INSERT INTO url_shortened (code, scan_id) VALUES ($1, $2) RETURNING *;
-- name: GetShortCode :one
SELECT * FROM url_shortened WHERE code = $1;
-- name: GetScanByID :one
SELECT * FROM scans WHERE id = $1;

View File

@@ -11,11 +11,20 @@ import (
"github.com/jackc/pgx/v5/pgtype"
)
const addSmsUsed = `-- name: AddSmsUsed :exec
UPDATE tags SET sms_used = sms_used + 1, updated_at = now() WHERE id = $1
`
func (q *Queries) AddSmsUsed(ctx context.Context, id int64) error {
_, err := q.db.Exec(ctx, addSmsUsed, id)
return err
}
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, sms_enabled, product_id, order_id
RETURNING id, tag_code, owner_id, status, item_type, description, photo_url, phone, address, notes, created_at, updated_at, sms_enabled, product_id, order_id, sms_allocated, sms_used, sms_period_start
`
type BindTagParams struct {
@@ -42,6 +51,9 @@ func (q *Queries) BindTag(ctx context.Context, arg BindTagParams) (Tag, error) {
&i.SmsEnabled,
&i.ProductID,
&i.OrderID,
&i.SmsAllocated,
&i.SmsUsed,
&i.SmsPeriodStart,
)
return i, err
}
@@ -50,7 +62,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, sms_enabled, product_id, order_id
RETURNING id, tag_code, owner_id, status, item_type, description, photo_url, phone, address, notes, created_at, updated_at, sms_enabled, product_id, order_id, sms_allocated, sms_used, sms_period_start
`
func (q *Queries) ClearTagOwner(ctx context.Context, id int64) (Tag, error) {
@@ -72,6 +84,9 @@ func (q *Queries) ClearTagOwner(ctx context.Context, id int64) (Tag, error) {
&i.SmsEnabled,
&i.ProductID,
&i.OrderID,
&i.SmsAllocated,
&i.SmsUsed,
&i.SmsPeriodStart,
)
return i, err
}
@@ -252,8 +267,41 @@ func (q *Queries) GetRecentScanByFingerprint(ctx context.Context, arg GetRecentS
return i, err
}
const getScanByID = `-- name: GetScanByID :one
SELECT id, tag_id, scanned_at, lat, lng, location_shared, scanner_phone, alert_sent, fingerprint, ip FROM scans WHERE id = $1
`
func (q *Queries) GetScanByID(ctx context.Context, id int64) (Scan, error) {
row := q.db.QueryRow(ctx, getScanByID, id)
var i Scan
err := row.Scan(
&i.ID,
&i.TagID,
&i.ScannedAt,
&i.Lat,
&i.Lng,
&i.LocationShared,
&i.ScannerPhone,
&i.AlertSent,
&i.Fingerprint,
&i.Ip,
)
return i, err
}
const getShortCode = `-- name: GetShortCode :one
SELECT code, scan_id, created_at FROM url_shortened WHERE code = $1
`
func (q *Queries) GetShortCode(ctx context.Context, code string) (UrlShortened, error) {
row := q.db.QueryRow(ctx, getShortCode, code)
var i UrlShortened
err := row.Scan(&i.Code, &i.ScanID, &i.CreatedAt)
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, sms_enabled, product_id, order_id 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, product_id, order_id, sms_allocated, sms_used, sms_period_start FROM tags WHERE tag_code = $1
`
func (q *Queries) GetTagByCode(ctx context.Context, tagCode string) (Tag, error) {
@@ -275,12 +323,15 @@ func (q *Queries) GetTagByCode(ctx context.Context, tagCode string) (Tag, error)
&i.SmsEnabled,
&i.ProductID,
&i.OrderID,
&i.SmsAllocated,
&i.SmsUsed,
&i.SmsPeriodStart,
)
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, sms_enabled, product_id, order_id 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, product_id, order_id, sms_allocated, sms_used, sms_period_start FROM tags WHERE id = $1
`
func (q *Queries) GetTagByID(ctx context.Context, id int64) (Tag, error) {
@@ -302,6 +353,9 @@ func (q *Queries) GetTagByID(ctx context.Context, id int64) (Tag, error) {
&i.SmsEnabled,
&i.ProductID,
&i.OrderID,
&i.SmsAllocated,
&i.SmsUsed,
&i.SmsPeriodStart,
)
return i, err
}
@@ -388,9 +442,25 @@ func (q *Queries) InsertScan(ctx context.Context, arg InsertScanParams) (Scan, e
return i, err
}
const insertShortCode = `-- name: InsertShortCode :one
INSERT INTO url_shortened (code, scan_id) VALUES ($1, $2) RETURNING code, scan_id, created_at
`
type InsertShortCodeParams struct {
Code string `json:"code"`
ScanID int64 `json:"scan_id"`
}
func (q *Queries) InsertShortCode(ctx context.Context, arg InsertShortCodeParams) (UrlShortened, error) {
row := q.db.QueryRow(ctx, insertShortCode, arg.Code, arg.ScanID)
var i UrlShortened
err := row.Scan(&i.Code, &i.ScanID, &i.CreatedAt)
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, sms_enabled, product_id, order_id
RETURNING id, tag_code, owner_id, status, item_type, description, photo_url, phone, address, notes, created_at, updated_at, sms_enabled, product_id, order_id, sms_allocated, sms_used, sms_period_start
`
func (q *Queries) InsertTag(ctx context.Context, tagCode string) (Tag, error) {
@@ -412,12 +482,15 @@ func (q *Queries) InsertTag(ctx context.Context, tagCode string) (Tag, error) {
&i.SmsEnabled,
&i.ProductID,
&i.OrderID,
&i.SmsAllocated,
&i.SmsUsed,
&i.SmsPeriodStart,
)
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, sms_enabled, product_id, order_id 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, product_id, order_id, sms_allocated, sms_used, sms_period_start FROM tags WHERE owner_id = $1 ORDER BY created_at DESC
`
func (q *Queries) ListTagsByOwner(ctx context.Context, ownerID pgtype.Int8) ([]Tag, error) {
@@ -445,6 +518,9 @@ func (q *Queries) ListTagsByOwner(ctx context.Context, ownerID pgtype.Int8) ([]T
&i.SmsEnabled,
&i.ProductID,
&i.OrderID,
&i.SmsAllocated,
&i.SmsUsed,
&i.SmsPeriodStart,
); err != nil {
return nil, err
}
@@ -503,7 +579,7 @@ const updateTagDetails = `-- name: UpdateTagDetails :one
UPDATE tags
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, sms_enabled, product_id, order_id
RETURNING id, tag_code, owner_id, status, item_type, description, photo_url, phone, address, notes, created_at, updated_at, sms_enabled, product_id, order_id, sms_allocated, sms_used, sms_period_start
`
type UpdateTagDetailsParams struct {
@@ -545,6 +621,9 @@ func (q *Queries) UpdateTagDetails(ctx context.Context, arg UpdateTagDetailsPara
&i.SmsEnabled,
&i.ProductID,
&i.OrderID,
&i.SmsAllocated,
&i.SmsUsed,
&i.SmsPeriodStart,
)
return i, err
}

View File

@@ -57,6 +57,7 @@ func LoadTemplates() (Templates, error) {
"account": {dir + "/account.html", dir + "/account-panel.html", dir + "/tag-list.html", dir + "/tag-edit-inline.html"},
"edit": {dir + "/tag-edit.html"},
"public": {dir + "/tag-public.html"},
"location": {dir + "/location.html"},
"notfound": {dir + "/not-found.html"},
}
funcs := template.FuncMap{"title": titleCase}

View File

@@ -0,0 +1,59 @@
package handlers
import (
"errors"
"net/http"
"github.com/jackc/pgx/v5"
"wherewoof/frontend/internal/db"
)
// locationData is passed to the owner's location page template.
type locationData struct {
Lat float64
Lng float64
FinderPhone string
TagCode string
ScannedAt string
}
// ServeShort resolves a short code to its scan and renders the owner's
// location page (map embed + finder details).
func (a *App) ServeShort(w http.ResponseWriter, r *http.Request) {
code := r.PathValue("code")
sc, err := a.Queries.GetShortCode(r.Context(), code)
if err != nil {
http.NotFound(w, r)
return
}
scan, err := a.Queries.GetScanByID(r.Context(), sc.ScanID)
if err != nil || !scan.Lat.Valid || !scan.Lng.Valid {
http.NotFound(w, r)
return
}
tag, err := a.Queries.GetTagByID(r.Context(), scan.TagID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
http.NotFound(w, r)
return
}
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
data := locationData{
Lat: scan.Lat.Float64,
Lng: scan.Lng.Float64,
FinderPhone: scan.ScannerPhone.String,
TagCode: tag.TagCode,
ScannedAt: scan.ScannedAt.Time.Local().Format("Mon 2 Jan, 3:04 pm"),
}
a.render(w, r, "location", "Location found", data, "")
}
// keep the db import referenced (Tag lookup above uses a.Queries only; this
// guards against accidental removal while the package evolves).
var _ = db.Tag{}

View File

@@ -5,6 +5,7 @@ import (
"encoding/json"
"errors"
"fmt"
"math/rand"
"net"
"net/http"
"strings"
@@ -76,6 +77,10 @@ func (a *App) Scan(w http.ResponseWriter, r *http.Request) {
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})
// Metering: a successful alert consumes one credit on metered tags.
if tag.SmsAllocated > 0 {
_ = a.Queries.AddSmsUsed(r.Context(), tag.ID)
}
}
}
@@ -107,6 +112,11 @@ func (a *App) shouldAlert(ctx context.Context, tag db.Tag, scan db.Scan, hasLoc
}
}
// SMS metering: a positive allocation is required; exhausted credits block alerts.
if tag.SmsAllocated > 0 && tag.SmsUsed >= tag.SmsAllocated {
return false
}
// Per-tag daily cap.
if n, err := a.Queries.CountAlertsByTagSince(ctx, db.CountAlertsByTagSinceParams{
TagID: tag.ID,
@@ -161,7 +171,15 @@ func (a *App) alertOwner(ctx context.Context, tag db.Tag, scan db.Scan, lat, lng
if a.Sender == nil {
return false
}
msg := alertMessage(tag, scan.ScannedAt.Time, lat, lng)
// When a location is present, mint a short link so the SMS stays short and
// the owner gets a map page instead of raw coordinates.
link := ""
if lat != nil && lng != nil {
if code, err := a.mintShortCode(ctx, scan.ID); err == nil {
link = "https://where-woof.com/s/" + code
}
}
msg := alertMessage(tag, scan.ScannedAt.Time, lat, lng, link)
if err := a.Sender.Send(sms.NormalizeAU(tag.Phone.String), msg); err != nil {
fmt.Println("alert sms failed:", err)
return false
@@ -169,8 +187,24 @@ func (a *App) alertOwner(ctx context.Context, tag db.Tag, scan db.Scan, lat, lng
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 {
// mintShortCode creates a short URL code for a scan (with retry on collision).
func (a *App) mintShortCode(ctx context.Context, scanID int64) (string, error) {
const alphabet = "abcdefghijklmnopqrstuvwxyz23456789" // no 0/1/o/l
for i := 0; i < 3; i++ {
code := make([]byte, 6)
for j := range code {
code[j] = alphabet[rand.Intn(len(alphabet))]
}
c := string(code)
if _, err := a.Queries.InsertShortCode(ctx, db.InsertShortCodeParams{Code: c, ScanID: scanID}); err == nil {
return c, nil
}
}
return "", fmt.Errorf("short code collision")
}
// alertMessage builds the owner alert: item type + name, time, location link, tag link.
func alertMessage(tag db.Tag, t time.Time, lat, lng *float64, link string) string {
itemType := "Item"
if tag.ItemType.Valid {
itemType = titleCase(tag.ItemType.String)
@@ -185,7 +219,9 @@ func alertMessage(tag db.Tag, t time.Time, lat, lng *float64) string {
}
msg := fmt.Sprintf("Where Woof: your %s was scanned at %s.", label, t.Local().Format("3:04 pm"))
if lat != nil && lng != nil {
if link != "" {
msg += " Location: " + link
} else 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."
@@ -243,6 +279,10 @@ func (a *App) FinderContact(w http.ResponseWriter, r *http.Request) {
// Should we notify? Skip when the same phone was alerted within the window,
// or when this device (fingerprint) has already alerted in the last 24 h.
notify := tag.SmsEnabled && tag.Phone.Valid && a.Sender != nil
// Metering: metered tags must have credits remaining.
if notify && tag.SmsAllocated > 0 && tag.SmsUsed >= tag.SmsAllocated {
notify = false
}
if notify && fingerprint != "" {
if _, err := a.Queries.GetRecentScanByFingerprint(r.Context(), db.GetRecentScanByFingerprintParams{Fingerprint: pgtype.Text{String: fingerprint, Valid: true}, ID: latest.ID}); err == nil {
notify = false
@@ -267,6 +307,10 @@ func (a *App) FinderContact(w http.ResponseWriter, r *http.Request) {
} else {
// Mark alerted so dedup (same phone / same device) can see it.
_ = a.Queries.SetScanAlertSent(r.Context(), db.SetScanAlertSentParams{ID: latest.ID, AlertSent: true})
// Metering: successful contact SMS consumes a credit on metered tags.
if tag.SmsAllocated > 0 {
_ = a.Queries.AddSmsUsed(r.Context(), tag.ID)
}
}
}

View File

@@ -67,6 +67,7 @@ func main() {
mux.HandleFunc("GET /{$}", app.Home)
mux.HandleFunc("/{path...}", http.NotFound)
mux.HandleFunc("GET /t/{tag_code}", app.PublicTag)
mux.HandleFunc("GET /s/{code}", app.ServeShort)
mux.HandleFunc("POST /t/{tag_code}/scan", app.Scan)
mux.HandleFunc("POST /t/{tag_code}/contact", app.FinderContact)
mux.HandleFunc("GET /register", app.RegisterPage)

View File

@@ -0,0 +1,31 @@
{{define "content"}}
<div class="mx-auto max-w-2xl">
<div class="overflow-hidden rounded-xl border border-stone-200 bg-white shadow-sm">
<div class="bg-stone-900 px-6 py-5 text-white">
<h1 class="text-2xl font-bold">📍 Location found</h1>
<p class="mt-1 text-sm text-stone-300">Your Where Woof tag was scanned at {{.Data.ScannedAt}}. This is where it was reported:</p>
</div>
<div class="p-6">
<div class="overflow-hidden rounded-lg border border-stone-200">
<iframe
title="Reported location"
src="https://www.google.com/maps?q={{.Data.Lat}},{{.Data.Lng}}&amp;output=embed"
class="h-72 w-full"
loading="lazy"
referrerpolicy="no-referrer-when-downgrade"></iframe>
</div>
{{if .Data.FinderPhone}}
<div class="mt-4 flex items-center justify-between rounded bg-green-50 px-4 py-3 border border-green-200">
<dt class="font-medium text-sm">The finder left their number</dt>
<dd><a href="tel:{{.Data.FinderPhone}}" class="font-semibold text-green-700 hover:underline">{{.Data.FinderPhone}}</a></dd>
</div>
{{end}}
<p class="mt-4 text-center text-sm">
<a href="/t/{{.Data.TagCode}}" class="text-amber-600 hover:underline">View this tag's return details</a>
</p>
</div>
</div>
</div>
{{end}}

View File

@@ -1,24 +1,24 @@
## 1. Schema & Queries
- [ ] 1.1 `db/schema.sql`: idempotent ALTERs `tags.sms_allocated/used/period_start`; `url_shortened` table; `make db-up`
- [ ] 1.2 `queries.sql`: `InsertShortCode`, `GetShortCode` (:one), `GetScanByID` (:one), `AddSmsUsed` (:exec, `sms_used = sms_used + 1`); `make generate`; build
- [x] 1.1 `db/schema.sql`: idempotent ALTERs `tags.sms_allocated/used/period_start`; `url_shortened` table; `make db-up`
- [x] 1.2 `queries.sql`: `InsertShortCode`, `GetShortCode` (:one), `GetScanByID` (:one), `AddSmsUsed` (:exec, `sms_used = sms_used + 1`); `make generate`; build
## 2. Metering
- [ ] 2.1 `scan.go` shouldAlert: credit check (`SmsAllocated > 0 && SmsUsed >= SmsAllocated` → false); after successful alert send → `AddSmsUsed`
- [ ] 2.2 `FinderContact`: same credit check + increment on successful send
- [x] 2.1 `scan.go` shouldAlert: credit check (`SmsAllocated > 0 && SmsUsed >= SmsAllocated` → false); after successful alert send → `AddSmsUsed`
- [x] 2.2 `FinderContact`: same credit check + increment on successful send
## 3. Location Link
- [ ] 3.1 Short-code generator + `InsertShortCode` at alert time (when location present); `alertMessage` uses `where-woof.com/s/{code}`
- [ ] 3.2 `ServeShort` handler (`GET /s/{code}` → scan → location page); `location.html` template (map iframe + finder phone + tag link + time); register route + template set
- [x] 3.1 Short-code generator + `InsertShortCode` at alert time (when location present); `alertMessage` uses `where-woof.com/s/{code}`
- [x] 3.2 `ServeShort` handler (`GET /s/{code}` → scan → location page); `location.html` template (map iframe + finder phone + tag link + time); register route + template set
## 4. Admin
- [ ] 4.1 TagResource: `sms_allocated` (numeric, editable), `sms_used` (view), `sms_period_start` (view)
- [x] 4.1 TagResource: `sms_allocated` (numeric, editable), `sms_used` (view), `sms_period_start` (view)
## 5. Verification
- [ ] 5.1 New suite: metered tag with credits → alert + credit consumed; exhausted → no alert (scan recorded); unmetered (0) → alerts; alert SMS contains `where-woof.com/s/`; short link → location page 200 with map + finder details; unknown code → not found
- [ ] 5.2 Regressions (22+19+12+6) green; deploy frontend + admin; live check
- [ ] 5.3 `openspec validate sms-metering-location-link`; commit
- [x] 5.1 New suite: metered tag with credits → alert + credit consumed; exhausted → no alert (scan recorded); unmetered (0) → alerts; alert SMS contains `where-woof.com/s/`; short link → location page 200 with map + finder details; unknown code → not found
- [x] 5.2 Regressions (22+19+12+6) green; deploy frontend + admin; live check
- [x] 5.3 `openspec validate sms-metering-location-link`; commit

View File

@@ -1,4 +1,4 @@
x 2026-08-08 (A) FIX: where-woof.com DNS/redirect -> router public IP (zone editor A records, worked after propagation) +project:where-woof +agent:where_woof
x 2026-08-08 2026-08-08 FIX: where-woof.com DNS/redirect -> router public IP (zone editor A records, worked after propagation) +project:where-woof +agent:where_woof
x 2026-08-08 (A) Deploy Laravel admin to .13 (Phase 8) — live on :3031 (3030=Langfuse), Caddy block updated +project:where-woof +agent:where_woof
x 2026-08-08 (A) Fix admin post-login 403 (User implements FilamentUser) + mixed-content CSS (forceScheme) +project:where-woof +agent:where_woof
x 2026-08-07 (A) openspec: init + project.md + change frontend-foundation +project:where-woof +agent:where_woof
@@ -18,3 +18,11 @@ x 2026-08-07 (B) Preset tag-ID registry: seed real manufactured IDs, enforce reg
(C) Photo uploads + object storage (Phase 6 — basic local upload done) +project:where-woof +agent:where_woof
(C) Anti-abuse hardening (relay numbers, rate limits — Phase 9) +project:where-woof +agent:where_woof
(C) Live SMS creds on .13 service env (uncomment SMS_USER/PASSWORD/FROM in ~/.config/where-woof.env) +project:where-woof +agent:where_woof
x 2026-08-08 2026-08-08 Billing phase +
(A) Customer insights eg total tags, customers, revenue +area:Dashboard-home
(A) Customers Tags +area:Dashboard-users
(A) Tags are associated with a customer, so no new account needed +area:concept
(A) Pause customer - unpaid +area:Dashboard-user
(A) Do we tie this all into an established Open Source inventory payment system - keep qick dash for now? +area:CONCEPT
(A) Order-tags. Tie in the inventory system? +area:Where-woof
(A) General webiste pages - what is this? Get in touch? About Us. +area:Where-woof