diff --git a/db/schema.sql b/db/schema.sql index bab7c84..03d5c5e 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -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() +); diff --git a/frontend/internal/db/models.go b/frontend/internal/db/models.go index 58ca684..0517392 100644 --- a/frontend/internal/db/models.go +++ b/frontend/internal/db/models.go @@ -39,21 +39,30 @@ type Scan struct { } type Tag struct { - ID int64 `json:"id"` - TagCode string `json:"tag_code"` - OwnerID pgtype.Int8 `json:"owner_id"` - Status string `json:"status"` - ItemType pgtype.Text `json:"item_type"` - Description pgtype.Text `json:"description"` - PhotoUrl pgtype.Text `json:"photo_url"` - Phone pgtype.Text `json:"phone"` - Address pgtype.Text `json:"address"` - Notes pgtype.Text `json:"notes"` - CreatedAt pgtype.Timestamptz `json:"created_at"` - UpdatedAt pgtype.Timestamptz `json:"updated_at"` - SmsEnabled bool `json:"sms_enabled"` - ProductID pgtype.Int8 `json:"product_id"` - OrderID pgtype.Int8 `json:"order_id"` + ID int64 `json:"id"` + TagCode string `json:"tag_code"` + OwnerID pgtype.Int8 `json:"owner_id"` + Status string `json:"status"` + ItemType pgtype.Text `json:"item_type"` + Description pgtype.Text `json:"description"` + PhotoUrl pgtype.Text `json:"photo_url"` + Phone pgtype.Text `json:"phone"` + Address pgtype.Text `json:"address"` + Notes pgtype.Text `json:"notes"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + 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 { diff --git a/frontend/internal/db/querier.go b/frontend/internal/db/querier.go index a7d439b..634ab6d 100644 --- a/frontend/internal/db/querier.go +++ b/frontend/internal/db/querier.go @@ -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 diff --git a/frontend/internal/db/queries.sql b/frontend/internal/db/queries.sql index fa0c4c9..95be2b3 100644 --- a/frontend/internal/db/queries.sql +++ b/frontend/internal/db/queries.sql @@ -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; diff --git a/frontend/internal/db/queries.sql.go b/frontend/internal/db/queries.sql.go index c4ad5a2..9d332a3 100644 --- a/frontend/internal/db/queries.sql.go +++ b/frontend/internal/db/queries.sql.go @@ -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 } diff --git a/frontend/internal/handlers/handlers.go b/frontend/internal/handlers/handlers.go index b70fcfa..6cd5cdc 100644 --- a/frontend/internal/handlers/handlers.go +++ b/frontend/internal/handlers/handlers.go @@ -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} diff --git a/frontend/internal/handlers/location.go b/frontend/internal/handlers/location.go new file mode 100644 index 0000000..e3cc4fd --- /dev/null +++ b/frontend/internal/handlers/location.go @@ -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{} diff --git a/frontend/internal/handlers/scan.go b/frontend/internal/handlers/scan.go index 653f76b..6fc41b3 100644 --- a/frontend/internal/handlers/scan.go +++ b/frontend/internal/handlers/scan.go @@ -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) + } } } diff --git a/frontend/main.go b/frontend/main.go index 235f46f..3c5edd9 100644 --- a/frontend/main.go +++ b/frontend/main.go @@ -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) diff --git a/frontend/templates/location.html b/frontend/templates/location.html new file mode 100644 index 0000000..141cffc --- /dev/null +++ b/frontend/templates/location.html @@ -0,0 +1,31 @@ +{{define "content"}} +
Your Where Woof tag was scanned at {{.Data.ScannedAt}}. This is where it was reported:
+