tag-lifecycle: owner close (status closed, gating + page) + move (copy details to new tag, close source) + legacy ?productid= redirect — verified with real tags 6 & 17

This commit is contained in:
2026-08-10 11:36:11 +10:00
parent 77a9f803f2
commit 97cf0974a3
8 changed files with 123 additions and 10 deletions

View File

@@ -16,7 +16,7 @@ CREATE TABLE IF NOT EXISTS tags (
tag_code TEXT NOT NULL UNIQUE, -- printed on QR + NFC, public identifier tag_code TEXT NOT NULL UNIQUE, -- printed on QR + NFC, public identifier
owner_id BIGINT REFERENCES users(id), owner_id BIGINT REFERENCES users(id),
status TEXT NOT NULL DEFAULT 'unset' status TEXT NOT NULL DEFAULT 'unset'
CHECK (status IN ('unset', 'active', 'suspended')), CHECK (status IN ('unset', 'active', 'suspended', 'closed')),
item_type TEXT CHECK (item_type IN ('dog', 'cat', 'baggage', 'skis', 'other')), item_type TEXT CHECK (item_type IN ('dog', 'cat', 'baggage', 'skis', 'other')),
description TEXT, description TEXT,
photo_url TEXT, photo_url TEXT,
@@ -93,3 +93,7 @@ ALTER TABLE orders ADD COLUMN IF NOT EXISTS amount NUMERIC(10,2) NOT NULL DEFAUL
-- Renewal tracking (lazy expiry): paid orders past renews_at are treated lapsed. -- Renewal tracking (lazy expiry): paid orders past renews_at are treated lapsed.
ALTER TABLE orders ADD COLUMN IF NOT EXISTS renews_at TIMESTAMPTZ; ALTER TABLE orders ADD COLUMN IF NOT EXISTS renews_at TIMESTAMPTZ;
-- Tag lifecycle: closed state (owner close / moved-from). Idempotent.
ALTER TABLE tags DROP CONSTRAINT IF EXISTS tags_status_check;
ALTER TABLE tags ADD CONSTRAINT tags_status_check CHECK (status IN ('unset','active','suspended','closed'));

View File

@@ -103,6 +103,10 @@ func (a *App) shouldAlert(ctx context.Context, tag db.Tag, scan db.Scan, hasLoc
if !tag.Phone.Valid { if !tag.Phone.Valid {
return false, "" return false, ""
} }
// Closed or suspended tags never alert (page shows unavailable).
if tag.Status == "closed" || tag.Status == "suspended" {
return false, ""
}
// Customer pause (admin kill-switch): paused owner -> record only. // Customer pause (admin kill-switch): paused owner -> record only.
if tag.OwnerID.Valid { if tag.OwnerID.Valid {

View File

@@ -24,6 +24,11 @@ type publicData struct {
} }
func (a *App) Home(w http.ResponseWriter, r *http.Request) { func (a *App) Home(w http.ResponseWriter, r *http.Request) {
// Legacy physical QR URLs: /?x=5&productid=CODE -> /t/CODE
if pid := r.URL.Query().Get("productid"); pid != "" {
http.Redirect(w, r, "/t/"+pid, http.StatusFound)
return
}
a.render(w, r, "index", "Home", nil, "") a.render(w, r, "index", "Home", nil, "")
} }

View File

@@ -231,6 +231,88 @@ func (a *App) UploadPhoto(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, `<p class="mt-2 text-xs text-green-700">Photo uploaded ✓ — refresh to see it on the tag page.</p>`) fmt.Fprintf(w, `<p class="mt-2 text-xs text-green-700">Photo uploaded ✓ — refresh to see it on the tag page.</p>`)
} }
// CloseTag lets an owner close (retire) their tag. Data is kept; the tag page
// shows unavailable and alerts stop (HTMX: returns account-panel).
func (a *App) CloseTag(w http.ResponseWriter, r *http.Request) {
uid, _ := auth.GetUserID(r)
tag, ok := a.loadOwnedTag(w, r, uid)
if !ok {
return
}
if tag.Status == "closed" {
a.renderAccountPanel(w, r, uid, "This tag is already closed.")
return
}
if err := a.Queries.SetTagStatus(r.Context(), db.SetTagStatusParams{ID: tag.ID, Status: "closed"}); err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
a.renderAccountPanel(w, r, uid, "")
}
// MoveTag moves a tag's details to another (unset) tag code, then closes the
// source tag — the broken-tag replacement flow (HTMX: returns account-panel).
func (a *App) MoveTag(w http.ResponseWriter, r *http.Request) {
uid, _ := auth.GetUserID(r)
src, ok := a.loadOwnedTag(w, r, uid)
if !ok {
return
}
targetCode := strings.TrimSpace(r.FormValue("new_tag_code")) // preset IDs are case-sensitive — trim only
if targetCode == "" {
a.renderAccountPanel(w, r, uid, "Enter the new tag's code.")
return
}
if targetCode == src.TagCode {
a.renderAccountPanel(w, r, uid, "The new tag must be different from the current tag.")
return
}
target, err := a.Queries.GetTagByCode(r.Context(), targetCode)
if err != nil {
a.renderAccountPanel(w, r, uid, "That tag code wasn't found. Check the code on the new tag.")
return
}
if target.OwnerID.Valid || target.Status != "unset" {
a.renderAccountPanel(w, r, uid, "That tag is already claimed or not available.")
return
}
// Bind the target to this owner, copy all details, then close the source.
if _, err := a.Queries.BindTag(r.Context(), db.BindTagParams{OwnerID: ownerID(uid), TagCode: targetCode}); err != nil {
a.renderAccountPanel(w, r, uid, "Could not claim the new tag.")
return
}
if _, err := a.Queries.UpdateTagDetails(r.Context(), db.UpdateTagDetailsParams{
ID: target.ID,
ItemType: src.ItemType,
Description: src.Description,
PhotoUrl: src.PhotoUrl,
Phone: src.Phone,
Address: src.Address,
Notes: src.Notes,
SmsEnabled: src.SmsEnabled,
}); err != nil {
a.renderAccountPanel(w, r, uid, "Could not copy the details.")
return
}
if err := a.Queries.SetTagStatus(r.Context(), db.SetTagStatusParams{ID: src.ID, Status: "closed"}); err != nil {
a.renderAccountPanel(w, r, uid, "Details moved, but the old tag couldn't be closed.")
return
}
a.renderAccountPanel(w, r, uid, "")
}
// renderAccountPanel refreshes the My Tags panel (HTMX target) with an optional error.
func (a *App) renderAccountPanel(w http.ResponseWriter, r *http.Request, uid int64, errMsg string) {
tags, err := a.Queries.ListTagsByOwner(r.Context(), ownerID(uid))
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
a.renderPartial(w, "account", "account-panel", accountData{Tags: tags, AddError: errMsg})
}
func writeUploadMsg(w http.ResponseWriter, msg string) { func writeUploadMsg(w http.ResponseWriter, msg string) {
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintf(w, `<p class="mt-2 text-xs text-red-600">%s</p>`, msg) fmt.Fprintf(w, `<p class="mt-2 text-xs text-red-600">%s</p>`, msg)

View File

@@ -85,6 +85,8 @@ func main() {
mux.Handle("GET /account/tags/{id}/edit/inline", app.RequireAuth(app.EditInlinePage)) mux.Handle("GET /account/tags/{id}/edit/inline", app.RequireAuth(app.EditInlinePage))
mux.Handle("POST /account/tags/{id}/edit", app.RequireAuth(app.EditTag)) mux.Handle("POST /account/tags/{id}/edit", app.RequireAuth(app.EditTag))
mux.Handle("POST /account/tags/{id}/delete", app.RequireAuth(app.DeleteTag)) mux.Handle("POST /account/tags/{id}/delete", app.RequireAuth(app.DeleteTag))
mux.Handle("POST /account/tags/{id}/close", app.RequireAuth(app.CloseTag))
mux.Handle("POST /account/tags/{id}/move", app.RequireAuth(app.MoveTag))
mux.Handle("POST /account/tags/{id}/photo", app.RequireAuth(app.UploadPhoto)) mux.Handle("POST /account/tags/{id}/photo", app.RequireAuth(app.UploadPhoto))
mux.HandleFunc("GET /photos/{key...}", app.ServePhoto) mux.HandleFunc("GET /photos/{key...}", app.ServePhoto)

View File

@@ -17,14 +17,24 @@
<td class="px-4 py-2"> <td class="px-4 py-2">
{{if eq .Status "active"}}<span class="rounded-full bg-green-100 px-2 py-0.5 text-xs font-medium text-green-800">Active</span> {{if eq .Status "active"}}<span class="rounded-full bg-green-100 px-2 py-0.5 text-xs font-medium text-green-800">Active</span>
{{else if eq .Status "suspended"}}<span class="rounded-full bg-red-100 px-2 py-0.5 text-xs font-medium text-red-800">Suspended</span> {{else if eq .Status "suspended"}}<span class="rounded-full bg-red-100 px-2 py-0.5 text-xs font-medium text-red-800">Suspended</span>
{{else if eq .Status "closed"}}<span class="rounded-full bg-stone-200 px-2 py-0.5 text-xs font-medium text-stone-500">Closed</span>
{{else}}<span class="rounded-full bg-stone-200 px-2 py-0.5 text-xs font-medium text-stone-700">Unset</span>{{end}} {{else}}<span class="rounded-full bg-stone-200 px-2 py-0.5 text-xs font-medium text-stone-700">Unset</span>{{end}}
</td> </td>
<td class="px-4 py-2">{{if .ItemType.Valid}}{{.ItemType.String}}{{else}}—{{end}}</td> <td class="px-4 py-2">{{if .ItemType.Valid}}{{.ItemType.String}}{{else}}—{{end}}</td>
<td class="px-4 py-2 text-right"> <td class="px-4 py-2 text-right">
{{if ne .Status "closed"}}
<a href="/account/tags/{{.ID}}/edit" hx-get="/account/tags/{{.ID}}/edit/inline" hx-target="closest tr" hx-swap="outerHTML" class="text-amber-600 hover:underline">Edit</a> <a href="/account/tags/{{.ID}}/edit" hx-get="/account/tags/{{.ID}}/edit/inline" hx-target="closest tr" hx-swap="outerHTML" class="text-amber-600 hover:underline">Edit</a>
{{end}}
<form hx-post="/account/tags/{{.ID}}/close" hx-target="#account-panel" hx-swap="outerHTML" hx-confirm="Close this tag? It will stop alerts but keep its details." class="inline">
<button class="ml-3 text-stone-500 hover:underline">Close</button>
</form>
<form hx-post="/account/tags/{{.ID}}/delete" hx-target="#account-panel" hx-swap="outerHTML" hx-confirm="Remove this tag from your account?" class="inline fade-row"> <form hx-post="/account/tags/{{.ID}}/delete" hx-target="#account-panel" hx-swap="outerHTML" hx-confirm="Remove this tag from your account?" class="inline fade-row">
<button class="ml-3 text-red-600 hover:underline">Remove</button> <button class="ml-3 text-red-600 hover:underline">Remove</button>
</form> </form>
<form hx-post="/account/tags/{{.ID}}/move" hx-target="#account-panel" hx-swap="outerHTML" class="inline" onsubmit="var c=prompt('Move details to which new tag code?'); if(!c)return false; this.querySelector('input').value=c;">
<input type="hidden" name="new_tag_code" value="">
<button class="ml-3 text-blue-600 hover:underline" type="submit">Move</button>
</form>
</td> </td>
</tr> </tr>
{{end}} {{end}}

View File

@@ -16,6 +16,12 @@
<h1 class="mt-4 text-2xl font-bold">This tag is unavailable</h1> <h1 class="mt-4 text-2xl font-bold">This tag is unavailable</h1>
<p class="mt-2 text-stone-600">The owner has disabled this tag. Please try another way to return the item.</p> <p class="mt-2 text-stone-600">The owner has disabled this tag. Please try another way to return the item.</p>
</div> </div>
{{else if eq .Data.Status "closed"}}
<div class="rounded-xl border border-stone-200 bg-white p-8 text-center shadow-sm">
<div class="text-5xl">🔒</div>
<h1 class="mt-4 text-2xl font-bold">This tag has been closed</h1>
<p class="mt-2 text-stone-600">This tag is no longer active. If you found this item, please try to return it another way.</p>
</div>
{{else}} {{else}}
<div class="overflow-hidden rounded-xl border border-stone-200 bg-white shadow-sm"> <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"> <div class="bg-stone-900 px-6 py-5 text-white">

View File

@@ -1,20 +1,20 @@
## 1. Schema ## 1. Schema
- [ ] 1.1 `db/schema.sql`: status CHECK gains `closed` (update CREATE + idempotent drop/re-add ALTER); `make db-up` - [x] 1.1 `db/schema.sql`: status CHECK gains `closed` (update CREATE + idempotent drop/re-add ALTER); `make db-up`
## 2. Go ## 2. Go
- [ ] 2.1 `shouldAlert`: return false when status `closed` or `suspended` - [x] 2.1 `shouldAlert`: return false when status `closed` or `suspended`
- [ ] 2.2 `tags.go`: `CloseTag` + `MoveTag` handlers (owner-only, HTMX panel responses) - [x] 2.2 `tags.go`: `CloseTag` + `MoveTag` handlers (owner-only, HTMX panel responses)
- [ ] 2.3 `Home` compat redirect: `productid` query → `/t/{productid}` - [x] 2.3 `Home` compat redirect: `productid` query → `/t/{productid}`
- [ ] 2.4 Templates: tag-list Close + Move actions; tag-public closed state; routes in main.go - [x] 2.4 Templates: tag-list Close + Move actions; tag-public closed state; routes in main.go
## 3. Admin ## 3. Admin
- [ ] 3.1 TagResource status options + badge include `closed` - [x] 3.1 TagResource status options + badge include `closed`
## 4. Verification ## 4. Verification
- [ ] 4.1 New suite: close → page unavailable + no alert; move → target bound w/ copied details + source closed; move errors (owned/unset-missing/same); `/?x=5&productid=CODE` → 302 `/t/CODE` - [x] 4.1 New suite: close → page unavailable + no alert; move → target bound w/ copied details + source closed; move errors (owned/unset-missing/same); `/?x=5&productid=CODE` → 302 `/t/CODE`
- [ ] 4.2 Regressions (72) green; deploy - [x] 4.2 Regressions (72) green; deploy
- [ ] 4.3 `openspec validate tag-lifecycle`; commit - [x] 4.3 `openspec validate tag-lifecycle`; commit