admin-insights-pages: dashboard customers+revenue (orders.amount), users->tags relation, pause customer (gates alerts), About/What-is-this/Contact pages

This commit is contained in:
2026-08-10 09:19:14 +10:00
parent d80c08306c
commit e1044f8c32
12 changed files with 119 additions and 14 deletions

View File

@@ -86,3 +86,7 @@ CREATE TABLE IF NOT EXISTS url_shortened (
scan_id BIGINT NOT NULL REFERENCES scans(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Phase 5.5 quick wins: customer pause + order amounts (idempotent).
ALTER TABLE users ADD COLUMN IF NOT EXISTS paused BOOLEAN NOT NULL DEFAULT FALSE;
ALTER TABLE orders ADD COLUMN IF NOT EXISTS amount NUMERIC(10,2) NOT NULL DEFAULT 0;

View File

@@ -14,6 +14,7 @@ type Order struct {
Status string `json:"status"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
Amount pgtype.Numeric `json:"amount"`
}
type Product struct {
@@ -74,4 +75,5 @@ type User struct {
CreatedAt pgtype.Timestamptz `json:"created_at"`
IsAdmin bool `json:"is_admin"`
RememberToken pgtype.Text `json:"remember_token"`
Paused bool `json:"paused"`
}

View File

@@ -139,7 +139,7 @@ func (q *Queries) CountTagsByOwner(ctx context.Context, ownerID pgtype.Int8) (in
const createUser = `-- name: CreateUser :one
INSERT INTO users (email, password_hash, name, phone)
VALUES ($1, $2, $3, $4)
RETURNING id, email, password_hash, name, phone, created_at, is_admin, remember_token
RETURNING id, email, password_hash, name, phone, created_at, is_admin, remember_token, paused
`
type CreateUserParams struct {
@@ -166,6 +166,7 @@ func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, e
&i.CreatedAt,
&i.IsAdmin,
&i.RememberToken,
&i.Paused,
)
return i, err
}
@@ -221,7 +222,7 @@ func (q *Queries) GetLatestScanByTag(ctx context.Context, tagID int64) (Scan, er
}
const getOrderByID = `-- name: GetOrderByID :one
SELECT id, account_id, status, created_at, updated_at FROM orders WHERE id = $1
SELECT id, account_id, status, created_at, updated_at, amount FROM orders WHERE id = $1
`
func (q *Queries) GetOrderByID(ctx context.Context, id int64) (Order, error) {
@@ -233,6 +234,7 @@ func (q *Queries) GetOrderByID(ctx context.Context, id int64) (Order, error) {
&i.Status,
&i.CreatedAt,
&i.UpdatedAt,
&i.Amount,
)
return i, err
}
@@ -361,7 +363,7 @@ func (q *Queries) GetTagByID(ctx context.Context, id int64) (Tag, error) {
}
const getUserByEmail = `-- name: GetUserByEmail :one
SELECT id, email, password_hash, name, phone, created_at, is_admin, remember_token FROM users WHERE email = $1
SELECT id, email, password_hash, name, phone, created_at, is_admin, remember_token, paused FROM users WHERE email = $1
`
func (q *Queries) GetUserByEmail(ctx context.Context, email string) (User, error) {
@@ -376,12 +378,13 @@ func (q *Queries) GetUserByEmail(ctx context.Context, email string) (User, error
&i.CreatedAt,
&i.IsAdmin,
&i.RememberToken,
&i.Paused,
)
return i, err
}
const getUserByID = `-- name: GetUserByID :one
SELECT id, email, password_hash, name, phone, created_at, is_admin, remember_token FROM users WHERE id = $1
SELECT id, email, password_hash, name, phone, created_at, is_admin, remember_token, paused FROM users WHERE id = $1
`
func (q *Queries) GetUserByID(ctx context.Context, id int64) (User, error) {
@@ -396,6 +399,7 @@ func (q *Queries) GetUserByID(ctx context.Context, id int64) (User, error) {
&i.CreatedAt,
&i.IsAdmin,
&i.RememberToken,
&i.Paused,
)
return i, err
}

View File

@@ -52,6 +52,9 @@ func LoadTemplates() (Templates, error) {
base := dir + "/base.html"
pages := map[string][]string{
"index": {dir + "/index.html"},
"about": {dir + "/about.html"},
"what": {dir + "/what.html"},
"contact": {dir + "/contact.html"},
"register": {dir + "/register.html"},
"login": {dir + "/login.html"},
"account": {dir + "/account.html", dir + "/account-panel.html", dir + "/tag-list.html", dir + "/tag-edit-inline.html"},

View File

@@ -0,0 +1,18 @@
package handlers
import "net/http"
// About renders the About page.
func (a *App) About(w http.ResponseWriter, r *http.Request) {
a.render(w, r, "about", "About", nil, "")
}
// WhatIsThis renders the "what is this?" explainer page.
func (a *App) WhatIsThis(w http.ResponseWriter, r *http.Request) {
a.render(w, r, "what", "What is this?", nil, "")
}
// Contact renders the contact page.
func (a *App) Contact(w http.ResponseWriter, r *http.Request) {
a.render(w, r, "contact", "Contact", nil, "")
}

View File

@@ -98,6 +98,13 @@ func (a *App) shouldAlert(ctx context.Context, tag db.Tag, scan db.Scan, hasLoc
return false
}
// Customer pause (admin kill-switch): paused owner -> record only.
if tag.OwnerID.Valid {
if owner, err := a.Queries.GetUserByID(ctx, tag.OwnerID.Int64); err == nil && owner.Paused {
return false
}
}
// 24 h per-device block: same fingerprint seen recently (excluding this scan) => record only.
if fp := scan.Fingerprint.String; fp != "" {
if _, err := a.Queries.GetRecentScanByFingerprint(ctx, db.GetRecentScanByFingerprintParams{Fingerprint: pgtype.Text{String: fp, Valid: true}, ID: scan.ID}); err == nil {
@@ -279,6 +286,12 @@ 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
// Customer pause: paused owner -> store only.
if notify && tag.OwnerID.Valid {
if owner, err := a.Queries.GetUserByID(r.Context(), tag.OwnerID.Int64); err == nil && owner.Paused {
notify = false
}
}
// Metering: metered tags must have credits remaining.
if notify && tag.SmsAllocated > 0 && tag.SmsUsed >= tag.SmsAllocated {
notify = false

View File

@@ -65,6 +65,9 @@ func main() {
mux := http.NewServeMux()
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.Dir("static"))))
mux.HandleFunc("GET /{$}", app.Home)
mux.HandleFunc("GET /about", app.About)
mux.HandleFunc("GET /what-is-this", app.WhatIsThis)
mux.HandleFunc("GET /contact", app.Contact)
mux.HandleFunc("/{path...}", http.NotFound)
mux.HandleFunc("GET /t/{tag_code}", app.PublicTag)
mux.HandleFunc("GET /s/{code}", app.ServeShort)

View File

@@ -0,0 +1,21 @@
{{define "content"}}
<div class="mx-auto max-w-2xl">
<h1 class="text-3xl font-extrabold tracking-tight">About Where Woof</h1>
<p class="mt-4 text-stone-700">
Where Woof makes it easy to get lost things — and lost dogs — home. Each Where Woof tag carries a
unique code. If your dog, baggage or gear goes missing, a finder scans the tag and sees exactly how
to return it: who to call, where to bring it, and how to reach you.
</p>
<p class="mt-3 text-stone-700">
When a finder scans your tag and shares their location, you get an instant SMS with a link to a map
showing where it was found. No app required — it just works from any phone.
</p>
<h2 class="mt-8 text-xl font-bold">How it works</h2>
<ul class="mt-3 space-y-2 text-stone-700">
<li>🏷️ Put the tag on your dog, luggage, skis — anything you care about.</li>
<li>📱 Claim it and set up your return details (description, photo, phone, address).</li>
<li>🔍 A finder scans the QR or taps the NFC tag and sees your details.</li>
<li>📍 If they share their location, you get an SMS with a map link.</li>
</ul>
</div>
{{end}}

View File

@@ -0,0 +1,11 @@
{{define "content"}}
<div class="mx-auto max-w-2xl">
<h1 class="text-3xl font-extrabold tracking-tight">Get in touch</h1>
<p class="mt-4 text-stone-700">Questions, lost-tag help, or bulk orders? We'd love to hear from you.</p>
<div class="mt-6 space-y-3 rounded-xl border border-stone-200 bg-white p-6 text-sm shadow-sm">
<p><strong>Email:</strong> <a href="mailto:info@where-woof.com" class="text-amber-600 hover:underline">info@where-woof.com</a></p>
<p><strong>Website:</strong> <a href="/" class="text-amber-600 hover:underline">where-woof.com</a></p>
<p class="text-stone-500">For bulk tags (parks, trails, campgrounds) mention your use case and volume.</p>
</div>
</div>
{{end}}

View File

@@ -17,8 +17,14 @@
<div class="text-2xl">🔍</div>
<h2 class="mt-2 font-bold">I found something</h2>
<p class="mt-1 text-sm text-stone-600">Scan the tag you found — the page shows the owner's return details.</p>
<a href="/account" class="mt-4 inline-block rounded bg-amber-500 px-4 py-2 text-sm font-semibold text-stone-900 hover:bg-amber-400">My account</a>
<a href="/what-is-this" class="mt-4 inline-block rounded bg-amber-500 px-4 py-2 text-sm font-semibold text-stone-900 hover:bg-amber-400">What is this?</a>
</div>
</div>
<p class="mt-10 text-center text-sm text-stone-500">
<a href="/about" class="hover:underline">About</a> ·
<a href="/what-is-this" class="hover:underline">What is this?</a> ·
<a href="/contact" class="hover:underline">Get in touch</a>
</p>
</div>
{{end}}

View File

@@ -0,0 +1,20 @@
{{define "content"}}
<div class="mx-auto max-w-2xl">
<h1 class="text-3xl font-extrabold tracking-tight">What is this?</h1>
<p class="mt-4 text-stone-700">
You've scanned a Where Woof tag. This page belongs to the item's owner and shows how to return it.
</p>
<div class="mt-6 rounded-xl border border-stone-200 bg-white p-6 shadow-sm">
<h2 class="font-bold">If you found this item</h2>
<ul class="mt-3 space-y-2 text-sm text-stone-700">
<li>📞 <strong>Call or text the owner</strong> using the details on this page.</li>
<li>📍 <strong>Share your location</strong> (the button on the page) — the owner gets an instant map link.</li>
<li>💬 <strong>Leave your number</strong> so the owner can contact you.</li>
</ul>
<p class="mt-4 text-sm text-stone-500">Thank you for helping bring something (or someone) home!</p>
</div>
<p class="mt-6 text-sm text-stone-500">
This tag belongs to a <a href="/about" class="text-amber-600 hover:underline">Where Woof</a> customer.
</p>
</div>
{{end}}

View File

@@ -1,17 +1,17 @@
## 1. Schema & Go
- [ ] 1.1 `db/schema.sql`: idempotent `users.paused` + `orders.amount`; `make db-up`; `make generate`
- [ ] 1.2 `scan.go`: `shouldAlert` + FinderContact — load owner, return false when paused
- [ ] 1.3 Static pages: `about.go` handlers (About/WhatIsThis/Contact) + templates + routes + template sets
- [x] 1.1 `db/schema.sql`: idempotent `users.paused` + `orders.amount`; `make db-up`; `make generate`
- [x] 1.2 `scan.go`: `shouldAlert` + FinderContact — load owner, return false when paused
- [x] 1.3 Static pages: `about.go` handlers (About/WhatIsThis/Contact) + templates + routes + template sets
## 2. Admin
- [ ] 2.1 StatsOverview: Customers + Revenue stats
- [ ] 2.2 OrderResource: `amount` input + column; UserResource: `paused` toggle + column
- [ ] 2.3 `UserResource/RelationManagers/TagsRelationManager.php` (code, status, item type)
- [x] 2.1 StatsOverview: Customers + Revenue stats
- [x] 2.2 OrderResource: `amount` input + column; UserResource: `paused` toggle + column
- [x] 2.3 `UserResource/RelationManagers/TagsRelationManager.php` (code, status, item type)
## 3. Verification
- [ ] 3.1 New suite: paused owner → no alert (scan recorded); unpaused → alert; /about /what-is-this /contact render 200
- [ ] 3.2 Regressions (72) green; deploy (frontend binary+templates; admin rebuild/restart)
- [ ] 3.3 `openspec validate admin-insights-pages`; commit
- [x] 3.1 New suite: paused owner → no alert (scan recorded); unpaused → alert; /about /what-is-this /contact render 200
- [x] 3.2 Regressions (72) green; deploy (frontend binary+templates; admin rebuild/restart)
- [x] 3.3 `openspec validate admin-insights-pages`; commit