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:
@@ -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).
|
-- Phase 5-lite: client IP on scans for rate limiting (idempotent).
|
||||||
ALTER TABLE scans ADD COLUMN IF NOT EXISTS ip TEXT;
|
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()
|
||||||
|
);
|
||||||
|
|||||||
@@ -54,6 +54,15 @@ type Tag struct {
|
|||||||
SmsEnabled bool `json:"sms_enabled"`
|
SmsEnabled bool `json:"sms_enabled"`
|
||||||
ProductID pgtype.Int8 `json:"product_id"`
|
ProductID pgtype.Int8 `json:"product_id"`
|
||||||
OrderID pgtype.Int8 `json:"order_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 {
|
type User struct {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Querier interface {
|
type Querier interface {
|
||||||
|
AddSmsUsed(ctx context.Context, id int64) error
|
||||||
BindTag(ctx context.Context, arg BindTagParams) (Tag, error)
|
BindTag(ctx context.Context, arg BindTagParams) (Tag, error)
|
||||||
ClearTagOwner(ctx context.Context, id int64) (Tag, error)
|
ClearTagOwner(ctx context.Context, id int64) (Tag, error)
|
||||||
CountAlertsByIPSince(ctx context.Context, arg CountAlertsByIPSinceParams) (int64, error)
|
CountAlertsByIPSince(ctx context.Context, arg CountAlertsByIPSinceParams) (int64, error)
|
||||||
@@ -21,11 +22,14 @@ type Querier interface {
|
|||||||
GetLatestScanByTag(ctx context.Context, tagID int64) (Scan, error)
|
GetLatestScanByTag(ctx context.Context, tagID int64) (Scan, error)
|
||||||
GetOrderByID(ctx context.Context, id int64) (Order, error)
|
GetOrderByID(ctx context.Context, id int64) (Order, error)
|
||||||
GetRecentScanByFingerprint(ctx context.Context, arg GetRecentScanByFingerprintParams) (Scan, 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)
|
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)
|
InsertScan(ctx context.Context, arg InsertScanParams) (Scan, error)
|
||||||
|
InsertShortCode(ctx context.Context, arg InsertShortCodeParams) (UrlShortened, 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
|
SetScanAlertSent(ctx context.Context, arg SetScanAlertSentParams) error
|
||||||
|
|||||||
@@ -85,3 +85,15 @@ WHERE tag_id = $1 AND alert_sent = TRUE AND scanned_at > $2;
|
|||||||
-- name: CountAlertsByIPSince :one
|
-- name: CountAlertsByIPSince :one
|
||||||
SELECT count(*) FROM scans
|
SELECT count(*) FROM scans
|
||||||
WHERE ip = $1 AND alert_sent = TRUE AND scanned_at > $2;
|
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;
|
||||||
|
|||||||
@@ -11,11 +11,20 @@ import (
|
|||||||
"github.com/jackc/pgx/v5/pgtype"
|
"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
|
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, 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 {
|
type BindTagParams struct {
|
||||||
@@ -42,6 +51,9 @@ func (q *Queries) BindTag(ctx context.Context, arg BindTagParams) (Tag, error) {
|
|||||||
&i.SmsEnabled,
|
&i.SmsEnabled,
|
||||||
&i.ProductID,
|
&i.ProductID,
|
||||||
&i.OrderID,
|
&i.OrderID,
|
||||||
|
&i.SmsAllocated,
|
||||||
|
&i.SmsUsed,
|
||||||
|
&i.SmsPeriodStart,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
@@ -50,7 +62,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, 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) {
|
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.SmsEnabled,
|
||||||
&i.ProductID,
|
&i.ProductID,
|
||||||
&i.OrderID,
|
&i.OrderID,
|
||||||
|
&i.SmsAllocated,
|
||||||
|
&i.SmsUsed,
|
||||||
|
&i.SmsPeriodStart,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
@@ -252,8 +267,41 @@ func (q *Queries) GetRecentScanByFingerprint(ctx context.Context, arg GetRecentS
|
|||||||
return i, err
|
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
|
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) {
|
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.SmsEnabled,
|
||||||
&i.ProductID,
|
&i.ProductID,
|
||||||
&i.OrderID,
|
&i.OrderID,
|
||||||
|
&i.SmsAllocated,
|
||||||
|
&i.SmsUsed,
|
||||||
|
&i.SmsPeriodStart,
|
||||||
)
|
)
|
||||||
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, 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) {
|
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.SmsEnabled,
|
||||||
&i.ProductID,
|
&i.ProductID,
|
||||||
&i.OrderID,
|
&i.OrderID,
|
||||||
|
&i.SmsAllocated,
|
||||||
|
&i.SmsUsed,
|
||||||
|
&i.SmsPeriodStart,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
@@ -388,9 +442,25 @@ func (q *Queries) InsertScan(ctx context.Context, arg InsertScanParams) (Scan, e
|
|||||||
return i, err
|
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
|
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, 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) {
|
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.SmsEnabled,
|
||||||
&i.ProductID,
|
&i.ProductID,
|
||||||
&i.OrderID,
|
&i.OrderID,
|
||||||
|
&i.SmsAllocated,
|
||||||
|
&i.SmsUsed,
|
||||||
|
&i.SmsPeriodStart,
|
||||||
)
|
)
|
||||||
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, 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) {
|
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.SmsEnabled,
|
||||||
&i.ProductID,
|
&i.ProductID,
|
||||||
&i.OrderID,
|
&i.OrderID,
|
||||||
|
&i.SmsAllocated,
|
||||||
|
&i.SmsUsed,
|
||||||
|
&i.SmsPeriodStart,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -503,7 +579,7 @@ const updateTagDetails = `-- name: UpdateTagDetails :one
|
|||||||
UPDATE tags
|
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()
|
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, 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 {
|
type UpdateTagDetailsParams struct {
|
||||||
@@ -545,6 +621,9 @@ func (q *Queries) UpdateTagDetails(ctx context.Context, arg UpdateTagDetailsPara
|
|||||||
&i.SmsEnabled,
|
&i.SmsEnabled,
|
||||||
&i.ProductID,
|
&i.ProductID,
|
||||||
&i.OrderID,
|
&i.OrderID,
|
||||||
|
&i.SmsAllocated,
|
||||||
|
&i.SmsUsed,
|
||||||
|
&i.SmsPeriodStart,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"},
|
"account": {dir + "/account.html", dir + "/account-panel.html", dir + "/tag-list.html", dir + "/tag-edit-inline.html"},
|
||||||
"edit": {dir + "/tag-edit.html"},
|
"edit": {dir + "/tag-edit.html"},
|
||||||
"public": {dir + "/tag-public.html"},
|
"public": {dir + "/tag-public.html"},
|
||||||
|
"location": {dir + "/location.html"},
|
||||||
"notfound": {dir + "/not-found.html"},
|
"notfound": {dir + "/not-found.html"},
|
||||||
}
|
}
|
||||||
funcs := template.FuncMap{"title": titleCase}
|
funcs := template.FuncMap{"title": titleCase}
|
||||||
|
|||||||
59
frontend/internal/handlers/location.go
Normal file
59
frontend/internal/handlers/location.go
Normal 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{}
|
||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"math/rand"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -76,6 +77,10 @@ func (a *App) Scan(w http.ResponseWriter, r *http.Request) {
|
|||||||
if alertSent {
|
if alertSent {
|
||||||
if a.alertOwner(r.Context(), tag, scan, req.Lat, req.Lng) {
|
if a.alertOwner(r.Context(), tag, scan, req.Lat, req.Lng) {
|
||||||
_ = a.Queries.SetScanAlertSent(r.Context(), db.SetScanAlertSentParams{ID: scan.ID, AlertSent: true})
|
_ = 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.
|
// Per-tag daily cap.
|
||||||
if n, err := a.Queries.CountAlertsByTagSince(ctx, db.CountAlertsByTagSinceParams{
|
if n, err := a.Queries.CountAlertsByTagSince(ctx, db.CountAlertsByTagSinceParams{
|
||||||
TagID: tag.ID,
|
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 {
|
if a.Sender == nil {
|
||||||
return false
|
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 {
|
if err := a.Sender.Send(sms.NormalizeAU(tag.Phone.String), msg); err != nil {
|
||||||
fmt.Println("alert sms failed:", err)
|
fmt.Println("alert sms failed:", err)
|
||||||
return false
|
return false
|
||||||
@@ -169,8 +187,24 @@ func (a *App) alertOwner(ctx context.Context, tag db.Tag, scan db.Scan, lat, lng
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// alertMessage builds the owner alert: item type + name, time, maps link, tag link.
|
// mintShortCode creates a short URL code for a scan (with retry on collision).
|
||||||
func alertMessage(tag db.Tag, t time.Time, lat, lng *float64) string {
|
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"
|
itemType := "Item"
|
||||||
if tag.ItemType.Valid {
|
if tag.ItemType.Valid {
|
||||||
itemType = titleCase(tag.ItemType.String)
|
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"))
|
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)
|
msg += fmt.Sprintf(" Location: https://maps.google.com/?q=%.5f,%.5f", *lat, *lng)
|
||||||
} else {
|
} else {
|
||||||
msg += " The finder didn't share a location."
|
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,
|
// 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.
|
// or when this device (fingerprint) has already alerted in the last 24 h.
|
||||||
notify := tag.SmsEnabled && tag.Phone.Valid && a.Sender != nil
|
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 notify && fingerprint != "" {
|
||||||
if _, err := a.Queries.GetRecentScanByFingerprint(r.Context(), db.GetRecentScanByFingerprintParams{Fingerprint: pgtype.Text{String: fingerprint, Valid: true}, ID: latest.ID}); err == nil {
|
if _, err := a.Queries.GetRecentScanByFingerprint(r.Context(), db.GetRecentScanByFingerprintParams{Fingerprint: pgtype.Text{String: fingerprint, Valid: true}, ID: latest.ID}); err == nil {
|
||||||
notify = false
|
notify = false
|
||||||
@@ -267,6 +307,10 @@ func (a *App) FinderContact(w http.ResponseWriter, r *http.Request) {
|
|||||||
} else {
|
} else {
|
||||||
// Mark alerted so dedup (same phone / same device) can see it.
|
// Mark alerted so dedup (same phone / same device) can see it.
|
||||||
_ = a.Queries.SetScanAlertSent(r.Context(), db.SetScanAlertSentParams{ID: latest.ID, AlertSent: true})
|
_ = 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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ func main() {
|
|||||||
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("GET /s/{code}", app.ServeShort)
|
||||||
mux.HandleFunc("POST /t/{tag_code}/scan", app.Scan)
|
mux.HandleFunc("POST /t/{tag_code}/scan", app.Scan)
|
||||||
mux.HandleFunc("POST /t/{tag_code}/contact", app.FinderContact)
|
mux.HandleFunc("POST /t/{tag_code}/contact", app.FinderContact)
|
||||||
mux.HandleFunc("GET /register", app.RegisterPage)
|
mux.HandleFunc("GET /register", app.RegisterPage)
|
||||||
|
|||||||
31
frontend/templates/location.html
Normal file
31
frontend/templates/location.html
Normal 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}}&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}}
|
||||||
@@ -1,24 +1,24 @@
|
|||||||
## 1. Schema & Queries
|
## 1. Schema & Queries
|
||||||
|
|
||||||
- [ ] 1.1 `db/schema.sql`: idempotent ALTERs `tags.sms_allocated/used/period_start`; `url_shortened` table; `make db-up`
|
- [x] 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.2 `queries.sql`: `InsertShortCode`, `GetShortCode` (:one), `GetScanByID` (:one), `AddSmsUsed` (:exec, `sms_used = sms_used + 1`); `make generate`; build
|
||||||
|
|
||||||
## 2. Metering
|
## 2. Metering
|
||||||
|
|
||||||
- [ ] 2.1 `scan.go` shouldAlert: credit check (`SmsAllocated > 0 && SmsUsed >= SmsAllocated` → false); after successful alert send → `AddSmsUsed`
|
- [x] 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.2 `FinderContact`: same credit check + increment on successful send
|
||||||
|
|
||||||
## 3. Location Link
|
## 3. Location Link
|
||||||
|
|
||||||
- [ ] 3.1 Short-code generator + `InsertShortCode` at alert time (when location present); `alertMessage` uses `where-woof.com/s/{code}`
|
- [x] 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.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. 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. 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
|
- [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
|
||||||
- [ ] 5.2 Regressions (22+19+12+6) green; deploy frontend + admin; live check
|
- [x] 5.2 Regressions (22+19+12+6) green; deploy frontend + admin; live check
|
||||||
- [ ] 5.3 `openspec validate sms-metering-location-link`; commit
|
- [x] 5.3 `openspec validate sms-metering-location-link`; commit
|
||||||
|
|||||||
10
todo.txt
10
todo.txt
@@ -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) 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-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
|
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) 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) 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
|
(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
|
||||||
|
|||||||
Reference in New Issue
Block a user