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:
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
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"
|
||||
"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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
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}}
|
||||
Reference in New Issue
Block a user